From 50e0148d551faeacbdabd0f434751409d48f88f0 Mon Sep 17 00:00:00 2001 From: krandder Date: Tue, 30 Jun 2026 04:14:27 +0100 Subject: [PATCH] Add tiered auto-qa tests --- .github/workflows/auto-qa-live.yml | 31 ++ .github/workflows/auto-qa.yml | 8 +- auto-qa/README.md | 37 ++ auto-qa/fixtures/known-graphql-failures.json | 86 +++ auto-qa/live/endpoint-liveness.test.mjs | 144 +++++ auto-qa/live/graphql-compat.test.mjs | 94 ++++ auto-qa/tests/algebra-pool-price.test.mjs | 344 ++++++++++++ auto-qa/tests/algebra-quoter.test.mjs | 362 +++++++++++++ auto-qa/tests/asset-refs.test.mjs | 131 +++++ auto-qa/tests/best-rpc-cache.test.mjs | 282 ++++++++++ auto-qa/tests/contract-addresses.test.mjs | 175 ++++++ auto-qa/tests/dead-references.test.mjs | 90 ++++ auto-qa/tests/extractor-sanity.test.mjs | 81 +++ auto-qa/tests/footer-links.test.mjs | 115 ++++ auto-qa/tests/format-number.test.mjs | 135 +++++ auto-qa/tests/futarchy-quote-helper.test.mjs | 337 ++++++++++++ auto-qa/tests/image-utils.test.mjs | 213 ++++++++ auto-qa/tests/impact-formula.test.mjs | 201 +++++++ auto-qa/tests/is-safe-wallet.test.mjs | 220 ++++++++ auto-qa/tests/json-config-validity.test.mjs | 146 +++++ auto-qa/tests/liquidity-math.test.mjs | 106 ++++ auto-qa/tests/pagination-first-cap.test.mjs | 137 +++++ auto-qa/tests/precision-formatter.test.mjs | 221 ++++++++ auto-qa/tests/proposal-doc-store.test.mjs | 267 ++++++++++ .../proposal-resolution-bucketing.test.mjs | 193 +++++++ auto-qa/tests/retry-backoff.test.mjs | 213 ++++++++ auto-qa/tests/rpc-config.test.mjs | 153 ++++++ auto-qa/tests/safe-tx-receipt.test.mjs | 187 +++++++ auto-qa/tests/sdai-rate-config.test.mjs | 153 ++++++ auto-qa/tests/seeded-random.test.mjs | 235 +++++++++ auto-qa/tests/slippage-math.test.mjs | 102 ++++ auto-qa/tests/snapshot-api.test.mjs | 333 ++++++++++++ auto-qa/tests/snapshot-id-extraction.test.mjs | 100 ++++ auto-qa/tests/sqrt-price-x96.test.mjs | 155 ++++++ .../subgraph-bulk-price-fetcher.test.mjs | 380 +++++++++++++ auto-qa/tests/subgraph-endpoints.test.mjs | 148 ++++++ auto-qa/tests/subgraph-pool-fetcher.test.mjs | 354 +++++++++++++ auto-qa/tests/subgraph-trades-client.test.mjs | 499 ++++++++++++++++++ auto-qa/tests/sushiswap-helper.test.mjs | 327 ++++++++++++ auto-qa/tests/swapr-sdk.test.mjs | 361 +++++++++++++ auto-qa/tests/twap-window.test.mjs | 121 +++++ .../tests/unified-balance-fetcher.test.mjs | 379 +++++++++++++ auto-qa/tests/url-shapes.test.mjs | 172 ++++++ auto-qa/tools/extract-graphql.mjs | 112 ++++ auto-qa/tools/probe-graphql.mjs | 206 ++++++++ package.json | 6 +- 46 files changed, 8847 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/auto-qa-live.yml create mode 100644 auto-qa/README.md create mode 100644 auto-qa/fixtures/known-graphql-failures.json create mode 100644 auto-qa/live/endpoint-liveness.test.mjs create mode 100644 auto-qa/live/graphql-compat.test.mjs create mode 100644 auto-qa/tests/algebra-pool-price.test.mjs create mode 100644 auto-qa/tests/algebra-quoter.test.mjs create mode 100644 auto-qa/tests/asset-refs.test.mjs create mode 100644 auto-qa/tests/best-rpc-cache.test.mjs create mode 100644 auto-qa/tests/contract-addresses.test.mjs create mode 100644 auto-qa/tests/dead-references.test.mjs create mode 100644 auto-qa/tests/extractor-sanity.test.mjs create mode 100644 auto-qa/tests/footer-links.test.mjs create mode 100644 auto-qa/tests/format-number.test.mjs create mode 100644 auto-qa/tests/futarchy-quote-helper.test.mjs create mode 100644 auto-qa/tests/image-utils.test.mjs create mode 100644 auto-qa/tests/impact-formula.test.mjs create mode 100644 auto-qa/tests/is-safe-wallet.test.mjs create mode 100644 auto-qa/tests/json-config-validity.test.mjs create mode 100644 auto-qa/tests/liquidity-math.test.mjs create mode 100644 auto-qa/tests/pagination-first-cap.test.mjs create mode 100644 auto-qa/tests/precision-formatter.test.mjs create mode 100644 auto-qa/tests/proposal-doc-store.test.mjs create mode 100644 auto-qa/tests/proposal-resolution-bucketing.test.mjs create mode 100644 auto-qa/tests/retry-backoff.test.mjs create mode 100644 auto-qa/tests/rpc-config.test.mjs create mode 100644 auto-qa/tests/safe-tx-receipt.test.mjs create mode 100644 auto-qa/tests/sdai-rate-config.test.mjs create mode 100644 auto-qa/tests/seeded-random.test.mjs create mode 100644 auto-qa/tests/slippage-math.test.mjs create mode 100644 auto-qa/tests/snapshot-api.test.mjs create mode 100644 auto-qa/tests/snapshot-id-extraction.test.mjs create mode 100644 auto-qa/tests/sqrt-price-x96.test.mjs create mode 100644 auto-qa/tests/subgraph-bulk-price-fetcher.test.mjs create mode 100644 auto-qa/tests/subgraph-endpoints.test.mjs create mode 100644 auto-qa/tests/subgraph-pool-fetcher.test.mjs create mode 100644 auto-qa/tests/subgraph-trades-client.test.mjs create mode 100644 auto-qa/tests/sushiswap-helper.test.mjs create mode 100644 auto-qa/tests/swapr-sdk.test.mjs create mode 100644 auto-qa/tests/twap-window.test.mjs create mode 100644 auto-qa/tests/unified-balance-fetcher.test.mjs create mode 100644 auto-qa/tests/url-shapes.test.mjs create mode 100644 auto-qa/tools/extract-graphql.mjs create mode 100644 auto-qa/tools/probe-graphql.mjs diff --git a/.github/workflows/auto-qa-live.yml b/.github/workflows/auto-qa-live.yml new file mode 100644 index 0000000..636b934 --- /dev/null +++ b/.github/workflows/auto-qa-live.yml @@ -0,0 +1,31 @@ +name: auto-qa live + +on: + workflow_dispatch: + inputs: + api_base: + description: 'Override api.futarchy.fi base URL for live checks' + required: false + default: 'https://api.futarchy.fi' + schedule: + - cron: '17 4 * * *' + +jobs: + live: + name: Live endpoint auto-qa + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Run live auto-qa tests + env: + AUTO_QA_API_BASE: ${{ inputs.api_base || 'https://api.futarchy.fi' }} + run: npm run auto-qa:test:live diff --git a/.github/workflows/auto-qa.yml b/.github/workflows/auto-qa.yml index d824698..1e13dd5 100644 --- a/.github/workflows/auto-qa.yml +++ b/.github/workflows/auto-qa.yml @@ -18,8 +18,8 @@ on: workflow_dispatch: jobs: - proposal-lifecycle: - name: Proposal lifecycle auto-qa + unit: + name: Deterministic auto-qa runs-on: ubuntu-latest timeout-minutes: 5 @@ -32,5 +32,5 @@ jobs: with: node-version: '22' - - name: Run auto-qa tests - run: npm run auto-qa:test + - name: Run deterministic auto-qa tests + run: npm run auto-qa:test:unit diff --git a/auto-qa/README.md b/auto-qa/README.md new file mode 100644 index 0000000..bc7ba00 --- /dev/null +++ b/auto-qa/README.md @@ -0,0 +1,37 @@ +# Auto-QA + +This directory is split into CI tiers by flakiness and external dependencies. + +## Deterministic Unit Tier + +Run: + +```sh +npm run auto-qa:test:unit +``` + +Files live under `auto-qa/tests/`. These tests must not call live services, +start browsers, start an Anvil fork, or submit transactions. They are allowed +to inspect local source files and run local Node helper scripts. This tier is +safe for pull-request CI and is also what `npm run auto-qa:test` runs. + +## Live Network Tier + +Run: + +```sh +npm run auto-qa:test:live +``` + +Files live under `auto-qa/live/`. These tests may call public Futarchy +endpoints and should skip when the network is unavailable. They are useful for +catching endpoint drift, but they are intentionally excluded from pull-request +CI. GitHub runs them only by manual dispatch or on the nightly schedule in +`.github/workflows/auto-qa-live.yml`. + +## Forked Browser Harness + +The larger Playwright and Anvil replay harness from the experimental +`auto-qa` branch is not part of this tier. Forked-chain reads, writes, browser +scenarios, and catalog drift checks need their own explicit workflow once they +are cleaned up and separated from live-write tests. diff --git a/auto-qa/fixtures/known-graphql-failures.json b/auto-qa/fixtures/known-graphql-failures.json new file mode 100644 index 0000000..5312ba0 --- /dev/null +++ b/auto-qa/fixtures/known-graphql-failures.json @@ -0,0 +1,86 @@ +{ + "knownFailureCount": 16, + "notes": "Baseline of GraphQL compat failures captured by auto-qa/tools/probe-graphql.mjs. These are production queries that fail validation against the live Checkpoint schema. Per the /loop directive: failures are documented but NOT fixed in this branch. When a production fix lands on main, regenerate with: npm run auto-qa:probe-graphql -- --baseline > auto-qa/fixtures/known-graphql-failures.json", + "failures": [ + { + "file": "src/components/debug/EditCompanyModal.jsx", + "line": 23, + "errorSignature": "Cannot query field \"organizationEntity\" on type \"Query\"." + }, + { + "file": "src/components/debug/EditProposalModal.jsx", + "line": 24, + "errorSignature": "Cannot query field \"proposalEntity\" on type \"Query\". Did you mean \"proposalentity\" or \"proposalentities\"?" + }, + { + "file": "src/components/debug/OrganizationManagerModal.jsx", + "line": 64, + "errorSignature": "Cannot query field \"organizations\" on type \"Aggregator\"." + }, + { + "file": "src/components/debug/OrganizationManagerModal.jsx", + "line": 65, + "errorSignature": "Cannot query field \"proposals\" on type \"Organization\"." + }, + { + "file": "src/components/futarchyFi/marketPage/MarketPageShowcase.jsx", + "line": 2028, + "errorSignature": "Syntax Error: Unexpected \"}\"." + }, + { + "file": "src/components/futarchyFi/proposalsList/page/proposalsPage/ProposalsPage.jsx", + "line": 111, + "errorSignature": "Cannot query field \"pools\" on type \"Proposal\"." + }, + { + "file": "src/hooks/useOrganization.js", + "line": 20, + "errorSignature": "Cannot query field \"proposals\" on type \"Organization\"." + }, + { + "file": "src/hooks/useSearchProposals.js", + "line": 20, + "errorSignature": "Cannot query field \"pools\" on type \"Proposal\"." + }, + { + "file": "src/hooks/useSearchProposals.js", + "line": 40, + "errorSignature": "Cannot query field \"pools\" on type \"Proposal\"." + }, + { + "file": "src/services/subgraphClient.js", + "line": 26, + "errorSignature": "Field \"proposal\" must not have a selection since type \"String\" has no subfields." + }, + { + "file": "src/services/subgraphClient.js", + "line": 46, + "errorSignature": "Field \"proposal\" must not have a selection since type \"String\" has no subfields." + }, + { + "file": "src/services/subgraphClient.js", + "line": 67, + "errorSignature": "Unknown type \"BigInt\". Did you mean \"Int\"?" + }, + { + "file": "src/services/subgraphClient.js", + "line": 89, + "errorSignature": "Cannot query field \"pools\" on type \"Proposal\"." + }, + { + "file": "src/utils/SubgraphBulkPriceFetcher.js", + "line": 36, + "errorSignature": "Syntax Error: Expected Name, found \"{\"." + }, + { + "file": "src/utils/SubgraphPoolFetcher.js", + "line": 132, + "errorSignature": "Syntax Error: Expected Name, found \"{\"." + }, + { + "file": "src/utils/subgraphTradesClient.js", + "line": 133, + "errorSignature": "String cannot represent a non string value: 0" + } + ] +} diff --git a/auto-qa/live/endpoint-liveness.test.mjs b/auto-qa/live/endpoint-liveness.test.mjs new file mode 100644 index 0000000..03d3bdf --- /dev/null +++ b/auto-qa/live/endpoint-liveness.test.mjs @@ -0,0 +1,144 @@ +/** + * Endpoint-liveness invariant (auto-qa). + * + * For every subgraph endpoint URL hardcoded in + * `src/config/subgraphEndpoints.js`, send a trivial GraphQL introspection + * query and assert: + * - HTTP 2xx + * - response is valid JSON with a `data.__schema` envelope (i.e. it's + * actually a working GraphQL endpoint, not a 200-OK landing page) + * + * Why: PRs #47, #49, #50, #60 all stemmed from the AWS → GCP migration + * leaving the frontend pointing at dead URLs. The dead endpoint returned + * a `database unavailable` body (so the page rendered empty silently). + * If any future endpoint drift, dead deploy, or URL typo lands on main, + * this test fails immediately with a clear message. + * + * The test reads the URLs out of the live source file (no copy-paste), + * so adding a new endpoint to subgraphEndpoints.js automatically extends + * the coverage. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ENDPOINTS_FILE = resolve(__dirname, '../../src/config/subgraphEndpoints.js'); +// PR #42: futarchy-api base URL referenced from usePoolData.js — extracted +// here so a regression in the env-var default URL is caught. +const API_BASE_URL_FILE = resolve(__dirname, '../../src/hooks/usePoolData.js'); + +/** + * Pull every `https://…/graphql` URL out of the endpoints config. + * Deliberately permissive: any string literal that ends in `/graphql` + * counts. Keeps the test resilient to refactors that rename the + * exported constants. + */ +function loadEndpointUrls() { + const text = readFileSync(ENDPOINTS_FILE, 'utf8'); + const matches = text.matchAll(/['"`](https?:\/\/[^'"`]+\/graphql)['"`]/g); + return [...new Set([...matches].map(m => m[1]))]; +} + +/** + * Pull the futarchy-api base URL out of usePoolData.js. The string + * literal we look for is the `||` fallback after the env-var read: + * process.env.NEXT_PUBLIC_POOL_API_URL || 'https://api.futarchy.fi' + * If the file is refactored to use a different default URL we want to + * notice. Returns null if not found (test then skips that case). + */ +function loadApiBaseUrl() { + let text; + try { text = readFileSync(API_BASE_URL_FILE, 'utf8'); } + catch { return null; } + const m = text.match(/NEXT_PUBLIC_POOL_API_URL\s*\|\|\s*['"`](https?:\/\/[^'"`]+)['"`]/); + return m ? m[1] : null; +} + +const INTROSPECTION = `{ __schema { queryType { name } } }`; + +async function probeEndpoint(url) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: INTROSPECTION }), + signal: AbortSignal.timeout(10000), + }); + let body = null; + try { body = await res.json(); } catch { /* non-JSON */ } + return { status: res.status, body }; +} + +const urls = loadEndpointUrls(); + +test('subgraphEndpoints.js contains at least one URL', () => { + assert.ok(urls.length > 0, + `expected to find at least one /graphql URL in ${ENDPOINTS_FILE}`); +}); + +for (const url of urls) { + test(`endpoint is live: ${url}`, async (t) => { + // Quick probe — if the URL is unreachable at all, skip rather than fail + // so we don't fail when the user is offline. But once we GET a response, + // demand it satisfies the schema-introspection invariant. + let result; + try { + result = await probeEndpoint(url); + } catch (err) { + t.skip(`network unreachable for ${url}: ${err.message}`); + return; + } + assert.ok(result.status >= 200 && result.status < 300, + `${url} returned HTTP ${result.status} (expected 2xx)`); + assert.ok(result.body && typeof result.body === 'object', + `${url} did not return JSON`); + assert.ok( + result.body.data?.__schema?.queryType?.name, + `${url} did not return a valid GraphQL introspection envelope. ` + + `Got: ${JSON.stringify(result.body).slice(0, 200)}…` + ); + }); +} + +// ──────────────────────────────────────────────────────────────────────── +// PR #42 — futarchy-api base URL (Express, not GraphQL) +// ──────────────────────────────────────────────────────────────────────── +const apiBaseUrl = loadApiBaseUrl(); + +test('PR #42 — usePoolData.js declares a futarchy-api base URL', () => { + assert.ok( + apiBaseUrl && /^https?:\/\//.test(apiBaseUrl), + `Expected to find a NEXT_PUBLIC_POOL_API_URL fallback URL in ` + + `${API_BASE_URL_FILE}. Was the constant renamed or removed?` + ); +}); + +test(`PR #42 — futarchy-api base URL is live: ${apiBaseUrl || '(not found)'}`, +async (t) => { + if (!apiBaseUrl) { t.skip('no api base URL discovered'); return; } + let res; + try { + res = await fetch(`${apiBaseUrl}/health`, { + signal: AbortSignal.timeout(10000), + }); + } catch (err) { + t.skip(`network unreachable for ${apiBaseUrl}: ${err.message}`); + return; + } + assert.ok(res.status >= 200 && res.status < 300, + `${apiBaseUrl}/health returned HTTP ${res.status} (expected 2xx)`); + + let body = null; + try { body = await res.json(); } catch { /* not JSON */ } + // /health on futarchy-api returns { status: 'ok', timestamp }; assert + // either that or simply that the body is non-empty so this test still + // passes if the health endpoint changes its shape harmlessly. + if (body && typeof body === 'object') { + assert.ok(body.status === 'ok' || body.timestamp || Object.keys(body).length > 0, + `${apiBaseUrl}/health body looks empty: ${JSON.stringify(body)}`); + } +}); + diff --git a/auto-qa/live/graphql-compat.test.mjs b/auto-qa/live/graphql-compat.test.mjs new file mode 100644 index 0000000..1f5142a --- /dev/null +++ b/auto-qa/live/graphql-compat.test.mjs @@ -0,0 +1,94 @@ +/** + * GraphQL schema-compat test (auto-qa). + * + * Runs the schema-compat probe against the live api.futarchy.fi and asserts + * the set of failing queries matches the baseline at + * auto-qa/fixtures/known-graphql-failures.json. + * + * Why a baseline rather than a hard "zero failures" assertion: + * The /loop directive forbids modifying production code on this branch, + * so we can't fix the broken queries we found. We instead pin the + * current state — any NEW failure (regression) trips the test, and any + * FIXED failure (production PR cleaned it up) also trips the test + * (forces baseline update). Both are useful signals. + * + * Skip behavior: if the live API is unreachable, skip with a clear message. + * The probe makes ~32 HTTP calls; this test is heavier than the others. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROBE = resolve(__dirname, '../tools/probe-graphql.mjs'); +const BASELINE = resolve(__dirname, '../fixtures/known-graphql-failures.json'); +const REPO_ROOT = resolve(__dirname, '../..'); +const API_BASE = process.env.AUTO_QA_API_BASE || 'https://api.futarchy.fi'; + +async function isApiReachable() { + try { + const resp = await fetch(`${API_BASE}/health`, { + method: 'GET', + signal: AbortSignal.timeout(5000), + }); + return resp.ok; + } catch { + return false; + } +} + +function runProbe() { + const out = execFileSync('node', [PROBE], { + encoding: 'utf8', + cwd: REPO_ROOT, + maxBuffer: 8 * 1024 * 1024, + }); + return JSON.parse(out); +} + +function loadBaseline() { + return JSON.parse(readFileSync(BASELINE, 'utf8')); +} + +test('GraphQL probe — failure count matches baseline', async (t) => { + if (!(await isApiReachable())) { + t.skip(`API at ${API_BASE} not reachable; skipping schema-compat probe`); + return; + } + const probe = runProbe(); + const baseline = loadBaseline(); + const actual = probe.results.filter(r => !r.ok); + + assert.equal( + actual.length, + baseline.knownFailureCount, + `Expected exactly ${baseline.knownFailureCount} failing queries (the known baseline); ` + + `got ${actual.length}. ` + + (actual.length > baseline.knownFailureCount + ? 'NEW FAILURES — likely a regression. Inspect with `npm run auto-qa:probe-graphql -- --summary`.' + : 'FEWER failures than baseline — a production fix likely landed. Regenerate the baseline file.') + ); +}); + +test('GraphQL probe — every failure is in the baseline (no surprise new ones)', async (t) => { + if (!(await isApiReachable())) { + t.skip(`API at ${API_BASE} not reachable; skipping`); + return; + } + const probe = runProbe(); + const baseline = loadBaseline(); + const actual = probe.results.filter(r => !r.ok); + const baselineKey = new Set(baseline.failures.map(f => `${f.file}:${f.line}`)); + + const surprises = actual.filter(a => !baselineKey.has(`${a.file}:${a.line}`)); + assert.equal( + surprises.length, 0, + `New failing queries not in baseline:\n${ + surprises.map(s => ` ${s.file}:${s.line} → ${s.graphqlErrors.join('; ')}`).join('\n') + }` + ); +}); diff --git a/auto-qa/tests/algebra-pool-price.test.mjs b/auto-qa/tests/algebra-pool-price.test.mjs new file mode 100644 index 0000000..0f2f715 --- /dev/null +++ b/auto-qa/tests/algebra-pool-price.test.mjs @@ -0,0 +1,344 @@ +/** + * getAlgebraPoolPrice spec mirror (auto-qa). + * + * Pins src/utils/getAlgebraPoolPrice.js — the RPC-rotating Algebra + * pool price reader used to fetch on-chain prices for non-Quoter paths + * (e.g. background spot watchers, dashboards). Has its own RPC-fallback + * + cooldown machinery distinct from getBestRpc.js. + * + * Five concerns: + * + * 1. POOL_ABI globalState DIVERGENCE — pinned that this file's + * 6-field tuple (uint160 price, int24 tick, uint16 lastFee, + * uint8 pluginConfig, uint16 communityFee, bool unlocked) is + * DIFFERENT from algebraQuoter.js's 7-field tuple (the older + * Algebra V3 shape with timepointIndex + communityFeeToken0/1). + * This file targets the newer Algebra Integral shape. A regression + * that flips them silently mis-decodes globalState in production. + * + * 2. GNOSIS_RPCS — 5 endpoints, HTTPS-only, deduplicated. Cross-pin + * vs the canonical lists in getRpcUrl.js / providers.jsx (already + * covered in rpc-config.test.mjs but here as a third occurrence + * worth pinning so the duplication is visible). + * + * 3. Constants — CACHE_DURATION = 30s, BASE_RETRY_DELAY = 30s, + * RANDOM_RETRY_RANGE = 10s, MOCK_MODE = false. + * + * 4. isRateLimitError — pure classifier across 7 indicators + * (429 code, "429" string, "rate limit", "cors", "too many + * requests", "network error", "fetch"). A regression that drops + * one means the corresponding error class slips past cooldown. + * + * 5. Price formula — `(Number(sqrtPriceX96) ** 2) / 2 ** 192`. + * Same Algebra V3 standard as sqrt-price-x96.test.mjs and + * algebra-quoter.test.mjs but written using the `**` operator. + * Pinned for cross-file consistency. + * + * 6. Module-level mutable state pattern — pinned via shape (count + * of Maps + the `let currentRpcIndex` declaration). A refactor + * to functional / class-based would surface here so cooldown + * semantics get re-evaluated. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const SRC = readFileSync( + new URL('../../src/utils/getAlgebraPoolPrice.js', import.meta.url), + 'utf8', +); +const ALGEBRA_QUOTER_SRC = readFileSync( + new URL('../../src/utils/algebraQuoter.js', import.meta.url), + 'utf8', +); + +// --- spec mirror of isRateLimitError (pure classifier) --- +function isRateLimitError(error) { + const msg = error.message?.toLowerCase() || ''; + const code = error.code; + return ( + code === 429 || + msg.includes('429') || + msg.includes('rate limit') || + msg.includes('cors') || + msg.includes('too many requests') || + msg.includes('network error') || + msg.includes('fetch') + ); +} + +// --- spec mirror of price formula --- +function priceFromSqrtX96(sqrtPriceX96) { + return (Number(sqrtPriceX96) ** 2) / 2 ** 192; +} + +// --------------------------------------------------------------------------- +// POOL_ABI — divergence from algebraQuoter.js (Integral vs V3 shape) +// --------------------------------------------------------------------------- + +test('POOL_ABI — globalState declares 6 fields (Integral shape: lastFee + pluginConfig)', () => { + // Pinned: this file's globalState struct expects the newer Algebra + // Integral shape: + // uint160 price, int24 tick, uint16 lastFee, uint8 pluginConfig, + // uint16 communityFee, bool unlocked + // (6 fields). A regression that flips this back to the V3 shape + // would mis-decode the 4th/5th fields silently. + // Source uses { type: ..., name: ... } order — pin name only. + assert.match(SRC, /name:\s*["']price["']/, + `POOL_ABI globalState first field name must be "price"`); + assert.match(SRC, /type:\s*["']uint160["'],\s*name:\s*["']price["']/, + `POOL_ABI globalState price field must be uint160`); + assert.match(SRC, /name:\s*["']lastFee["']/, + `POOL_ABI globalState must include lastFee (Integral shape)`); + assert.match(SRC, /name:\s*["']pluginConfig["']/, + `POOL_ABI globalState must include pluginConfig (Integral-only field)`); + assert.match(SRC, /name:\s*["']communityFee["']/, + `POOL_ABI globalState must include communityFee (single field, NOT split into Token0/Token1)`); +}); + +test('POOL_ABI — does NOT include timepointIndex (the older V3 field)', () => { + // Pinned the divergence. timepointIndex = old V3; pluginConfig = Integral. + assert.doesNotMatch(SRC, /timepointIndex/, + `POOL_ABI globalState must NOT include timepointIndex — that's the older V3 shape, ` + + `which is in algebraQuoter.js. These two files target DIFFERENT Algebra versions.`); +}); + +test('POOL_ABI — does NOT split communityFee into Token0/Token1 (V3-style)', () => { + // Pinned that this file uses the unified communityFee (Integral). + assert.doesNotMatch(SRC, /communityFeeToken[01]/, + `POOL_ABI must use single 'communityFee' (Integral), NOT 'communityFeeToken0/1' (V3)`); +}); + +test('cross-file divergence — algebraQuoter.js POOL_ABI uses the OLDER V3 7-field shape', () => { + // Sanity-pin: algebraQuoter still uses the V3 shape (with + // timepointIndex + Token0/1 community fees). If both files ever + // converge, that's a deliberate refactor — re-check both call sites. + assert.match(ALGEBRA_QUOTER_SRC, /timepointIndex/, + `algebraQuoter.js POOL_ABI must still include timepointIndex (V3 shape)`); + assert.match(ALGEBRA_QUOTER_SRC, /communityFeeToken[01]/, + `algebraQuoter.js POOL_ABI must still split communityFee into Token0/Token1 (V3 shape)`); +}); + +// --------------------------------------------------------------------------- +// GNOSIS_RPCS — 5 endpoints, HTTPS-only, deduplicated +// --------------------------------------------------------------------------- + +test('GNOSIS_RPCS — has exactly 5 entries (drift surfaces as more/fewer fallback options)', () => { + const m = SRC.match(/GNOSIS_RPCS\s*=\s*\[([\s\S]*?)\]/); + assert.ok(m); + const urls = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1]); + assert.equal(urls.length, 5, + `GNOSIS_RPCS drifted from 5 entries; got ${urls.length}. ` + + `Compare against rpc-config.test.mjs canonical list.`); +}); + +test('GNOSIS_RPCS — all entries are HTTPS', () => { + const m = SRC.match(/GNOSIS_RPCS\s*=\s*\[([\s\S]*?)\]/); + const urls = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1]); + for (const url of urls) { + assert.match(url, /^https:\/\//, + `GNOSIS_RPCS entry ${url} is not HTTPS — would leak request headers`); + } +}); + +test('GNOSIS_RPCS — entries are deduplicated', () => { + const m = SRC.match(/GNOSIS_RPCS\s*=\s*\[([\s\S]*?)\]/); + const urls = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1]); + assert.equal(new Set(urls).size, urls.length, + `GNOSIS_RPCS contains duplicates`); +}); + +// --------------------------------------------------------------------------- +// Constants — CACHE_DURATION, BASE_RETRY_DELAY, RANDOM_RETRY_RANGE, MOCK_MODE +// --------------------------------------------------------------------------- + +test('CACHE_DURATION — pinned at 30s (different from getBestRpc which is 5min)', () => { + // Pinned: 30s makes sense for pool prices (rapid change). Drift + // would either over-fetch (sub-30s) or show stale pool prices + // (longer than 30s). + assert.match(SRC, /CACHE_DURATION\s*=\s*30\s*\*\s*1000/, + `CACHE_DURATION drifted from 30 * 1000 (30s)`); +}); + +test('BASE_RETRY_DELAY — pinned at 30s', () => { + // Pinned: this is the cooldown window after a rate-limit error. + // Too short = thrashing; too long = inflated UX latency on flaky RPCs. + assert.match(SRC, /BASE_RETRY_DELAY\s*=\s*30\s*\*\s*1000/, + `BASE_RETRY_DELAY drifted from 30 * 1000 (30s base cooldown)`); +}); + +test('RANDOM_RETRY_RANGE — pinned at 10s (jitter window)', () => { + // Pinned: a 1-10s additional random delay smooths thundering-herd + // behavior on rate-limit recovery. + assert.match(SRC, /RANDOM_RETRY_RANGE\s*=\s*10\s*\*\s*1000/, + `RANDOM_RETRY_RANGE drifted from 10 * 1000 (10s jitter)`); +}); + +test('MOCK_MODE — pinned to FALSE in production source', () => { + // CRITICAL pin: MOCK_MODE=true returns deterministic mock data + // (0.98765 + random*0.02). Shipping with MOCK_MODE=true would + // serve fake prices to every consumer — silent catastrophe. + assert.match(SRC, /const MOCK_MODE\s*=\s*false/, + `MOCK_MODE must be false in source. Setting to true ships fake prices to every consumer.`); +}); + +// --------------------------------------------------------------------------- +// isRateLimitError — 7-indicator classifier (each indicator pinned) +// --------------------------------------------------------------------------- + +test('isRateLimitError — error.code === 429 → true', () => { + assert.equal(isRateLimitError({ code: 429 }), true); + assert.equal(isRateLimitError({ code: 429, message: 'whatever' }), true); +}); + +test('isRateLimitError — message contains "429" → true', () => { + assert.equal(isRateLimitError({ message: 'HTTP 429: Too Many Requests' }), true); +}); + +test('isRateLimitError — message contains "rate limit" → true (case-insensitive via toLowerCase)', () => { + assert.equal(isRateLimitError({ message: 'Rate Limit exceeded' }), true); + assert.equal(isRateLimitError({ message: 'rate limit ok' }), true); +}); + +test('isRateLimitError — message contains "cors" → true (CORS counts as rate-limit-like)', () => { + // Pinned: CORS errors are common when an RPC is misconfigured. The + // file lumps them in with rate-limits so the same cooldown + // applies. + assert.equal(isRateLimitError({ message: 'CORS blocked' }), true); + assert.equal(isRateLimitError({ message: 'cors policy' }), true); +}); + +test('isRateLimitError — message contains "too many requests" → true', () => { + assert.equal(isRateLimitError({ message: 'Too Many Requests' }), true); +}); + +test('isRateLimitError — message contains "network error" → true', () => { + assert.equal(isRateLimitError({ message: 'Network Error' }), true); +}); + +test('isRateLimitError — message contains "fetch" → true', () => { + // Pinned: covers fetch-related failures (e.g. "TypeError: fetch failed"). + assert.equal(isRateLimitError({ message: 'fetch failed' }), true); +}); + +test('isRateLimitError — non-matching error → false', () => { + assert.equal(isRateLimitError({ message: 'Internal server error', code: 500 }), false); + assert.equal(isRateLimitError({ message: 'Invalid params', code: -32602 }), false); +}); + +test('isRateLimitError — empty/missing message + non-429 code → false', () => { + assert.equal(isRateLimitError({}), false); + assert.equal(isRateLimitError({ message: null }), false); +}); + +// --------------------------------------------------------------------------- +// Price formula — sqrt² / 2^192 (Algebra V3 standard) +// --------------------------------------------------------------------------- + +test('priceFromSqrtX96 — sqrt = 2^96 → price = 1', () => { + // Cross-pin against canonical sqrt-price-x96 / algebra-quoter math. + // Note: this file uses Number(sqrt) ** 2 (NOT BigInt mul), losing + // precision at extreme prices. Pinned-as-is per /loop directive. + const r = priceFromSqrtX96((2n ** 96n).toString()); + assert.equal(r, 1); +}); + +test('priceFromSqrtX96 — non-negative for any non-negative sqrt (square invariant)', () => { + for (const exp of [0, 48, 96, 144, 160]) { + const r = priceFromSqrtX96((2n ** BigInt(exp)).toString()); + assert.ok(r >= 0, `sqrt=2^${exp} produced negative price ${r}`); + } +}); + +test('priceFromSqrtX96 — monotonic in sqrtPrice', () => { + const a = priceFromSqrtX96((1n << 96n).toString()); + const b = priceFromSqrtX96((2n << 96n).toString()); + const c = priceFromSqrtX96((10n << 96n).toString()); + assert.ok(a < b && b < c); +}); + +test('source — price formula uses ** operator (Number coercion path)', () => { + // Pinned: this file uses `(Number(sqrtPriceX96) ** 2) / 2 ** 192`, + // distinct stylistically from algebra-quoter.js's BigNumber.mul(). + // Both should produce the same value; pin the syntactic form so a + // refactor to BigInt would be deliberate (changes precision). + assert.match(SRC, + /\(Number\(sqrtPriceX96\)\s*\*\*\s*2\)\s*\/\s*2\s*\*\*\s*192/, + `price formula drifted from (Number(sqrt) ** 2) / 2 ** 192`); +}); + +// --------------------------------------------------------------------------- +// Cooldown / dedup machinery — source-text shape pins +// --------------------------------------------------------------------------- + +test('source — module-level state: 5 distinct Maps for caches + a single mutable index', () => { + // Pinned: providers, poolContracts, loadingStates, retryStates, + // rateLimitCooldowns. A refactor that consolidates these silently + // changes cache scoping. + const mapDecls = [...SRC.matchAll(/const\s+(\w+)\s*=\s*new Map\(\)/g)].map(m => m[1]); + assert.equal(mapDecls.length, 5, + `expected 5 module-level Map() declarations; got ${mapDecls.length}: ${mapDecls.join(', ')}`); + for (const name of ['providers', 'poolContracts', 'loadingStates', 'retryStates', 'rateLimitCooldowns']) { + assert.ok(mapDecls.includes(name), + `expected Map "${name}" not declared`); + } +}); + +test('source — currentRpcIndex is `let` (mutable round-robin counter)', () => { + // Pinned: `let` not `const` — getNextRpc / rotateRpc reassign it. + // A refactor to const would either stop rotating (always RPC 0) + // or throw on assignment. + assert.match(SRC, + /let\s+currentRpcIndex\s*=\s*0/, + `currentRpcIndex must be \`let\` initialized to 0 (mutable round-robin counter)`); +}); + +test('source — getNextRpc rotates round-robin via modulo (currentRpcIndex + 1) % len', () => { + // Pinned the rotation primitive. A regression to `currentRpcIndex++` + // (no modulo) would walk off the end into undefined. + const matches = [...SRC.matchAll(/currentRpcIndex\s*=\s*\(currentRpcIndex\s*\+\s*1\)\s*%\s*GNOSIS_RPCS\.length/g)]; + assert.ok(matches.length >= 2, + `expected >=2 round-robin rotation expressions; got ${matches.length}`); +}); + +test('source — callWithRpcFallback default maxRetries = 3', () => { + // Pinned: a regression that lowers to 1 means a single transient + // RPC error fails the entire call — UX cascade. + assert.match(SRC, + /callWithRpcFallback\(address,\s*maxRetries\s*=\s*3\)/, + `callWithRpcFallback default maxRetries drifted from 3`); +}); + +test('source — getAlgebraPoolPrice IMMEDIATE deduplication checks loadingStates BEFORE Date.now()', () => { + // Pinned: the comment says "IMMEDIATE deduplication - check all + // states first before doing ANYTHING". A regression that moves + // Date.now() ahead of the dedup check would re-enter the function + // for in-flight requests. + const fn = SRC.match(/export\s+async\s+function\s+getAlgebraPoolPrice\(poolConfig\)\s*\{([\s\S]*?)^\}/m); + assert.ok(fn); + const dedupIdx = fn[1].indexOf('loadingStates.has(address)'); + const nowIdx = fn[1].indexOf('Date.now()'); + assert.ok(dedupIdx > -1 && nowIdx > -1); + assert.ok(dedupIdx < nowIdx, + `loadingStates dedup check must come BEFORE Date.now() — IMMEDIATE dedup invariant`); +}); + +test('source — successful RPC call clears address-level cooldown', () => { + // Pinned: rateLimitCooldowns.delete(address) on success ensures a + // recovered RPC restores normal behavior immediately. A regression + // that drops this leaves stale cooldowns blocking valid responses. + assert.match(SRC, + /\/\/.*Clear any rate limit cooldown on success[\s\S]*rateLimitCooldowns\.delete\(address\)/, + `must clear rateLimitCooldowns on success — stale cooldowns block recovered RPCs`); +}); + +test('source — finally block ALWAYS removes loading state (no leak path)', () => { + // Pinned: loadingStates.delete(address) lives in `finally`, not + // after the try. A regression that moves it to the success path + // leaks the in-flight promise on errors → all subsequent calls + // wait on a rejected promise. + assert.match(SRC, + /\}\s*finally\s*\{[\s\S]*?loadingStates\.delete\(address\)/, + `loadingStates.delete must be in finally — error-path leak otherwise`); +}); diff --git a/auto-qa/tests/algebra-quoter.test.mjs b/auto-qa/tests/algebra-quoter.test.mjs new file mode 100644 index 0000000..872e186 --- /dev/null +++ b/auto-qa/tests/algebra-quoter.test.mjs @@ -0,0 +1,362 @@ +/** + * algebraQuoter spec mirror (auto-qa). + * + * Pins src/utils/algebraQuoter.js — the direct on-chain Algebra Quoter + * wrapper that replaced @swapr/sdk to drop quote latency from "420+ RPC + * calls per quote" to "1-4 RPC calls per quote". Powers swap-modal + * quotes for non-Futarchy pools. + * + * Six concerns pinned: + * + * 1. ALGEBRA_QUOTER contract address — canonical Gnosis address. + * Drift = quotes route to wrong/non-existent contract. + * 2. QUOTER_ABI / POOL_ABI / ERC20_ABI shapes — drift = ethers + * decodes garbage from callStatic returns. + * 3. getAlgebraQuote — callStatic with sqrtPriceLimitX96=0, error + * remapping (LOK / IIA / AS to human-readable strings). + * 4. sqrtPriceX96ToPrice (exported helper) — Algebra V3 standard + * formula. Cross-pinned against the canonical sqrt-price-x96.test.mjs. + * 5. Slippage math (default 50 bps = 0.5%) — same algorithm as the + * canonical helper. Cross-pin so divergence is visible. + * 6. Direction-detection (isToken0ToToken1 / shouldInvertPrice / + * executionPrice direction) — silent regression here = wrong + * price displayed in UI for one half of every pool. + * + * Async getAlgebraQuoteWithSlippage NOT mirrored (network-bound) — + * only its source-text branches and pure helpers. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const SRC = readFileSync( + new URL('../../src/utils/algebraQuoter.js', import.meta.url), + 'utf8', +); + +// --- spec mirror of sqrtPriceX96ToPrice (BigInt math) --- +function sqrtPriceX96ToPrice(sqrtPriceX96String) { + try { + const sqrt = BigInt(sqrtPriceX96String); + const Q96 = 2n ** 96n; + const sqrtSquared = sqrt * sqrt; + const Q192 = Q96 * Q96; + return Number(sqrtSquared) / Number(Q192); + } catch { + return null; + } +} + +// --- spec mirror of slippage math (BigInt-safe) --- +function applySlippageBps(amountOutWei, slippageBps) { + const factor = BigInt(10000 - slippageBps); + return (BigInt(amountOutWei) * factor) / 10000n; +} + +// --- spec mirror of token-direction detection --- +function detectDirection(tokenIn, tokenOut, token0, token1) { + const tInL = tokenIn.toLowerCase(); + const tOutL = tokenOut.toLowerCase(); + const t0L = token0.toLowerCase(); + const t1L = token1.toLowerCase(); + return { + isToken0ToToken1: tInL === t0L && tOutL === t1L, + isToken1ToToken0: tInL === t1L && tOutL === t0L, + }; +} + +// --------------------------------------------------------------------------- +// ALGEBRA_QUOTER — canonical Gnosis address +// --------------------------------------------------------------------------- + +test('ALGEBRA_QUOTER — pinned to canonical 0xcBaD9FDf...0F7 Gnosis Algebra Quoter', () => { + const m = SRC.match(/ALGEBRA_QUOTER\s*=\s*['"]([^'"]+)['"]/); + assert.ok(m, 'ALGEBRA_QUOTER not found'); + assert.equal(m[1], '0xcBaD9FDf0D2814659Eb26f600EFDeAF005Eda0F7', + `ALGEBRA_QUOTER drifted from canonical Gnosis Quoter — every non-Futarchy ` + + `quote calls this contract; drift means wrong contract / wrong quotes / silent revert.`); +}); + +test('ALGEBRA_QUOTER — valid 0x + 40 hex chars', () => { + const m = SRC.match(/ALGEBRA_QUOTER\s*=\s*['"]([^'"]+)['"]/); + assert.match(m[1], /^0x[0-9a-fA-F]{40}$/); +}); + +test('ALGEBRA_QUOTER — preserves EIP-55 mixed-case checksum form', () => { + const m = SRC.match(/ALGEBRA_QUOTER\s*=\s*['"]([^'"]+)['"]/); + assert.notEqual(m[1], m[1].toLowerCase(), + `ALGEBRA_QUOTER must preserve EIP-55 checksum case`); +}); + +// --------------------------------------------------------------------------- +// ABIs — exact shapes +// --------------------------------------------------------------------------- + +test('QUOTER_ABI — exact quoteExactInputSingle signature', () => { + // Pinned: (address tokenIn, address tokenOut, uint256 amountIn, + // uint160 sqrtPriceLimitX96) returns (uint256 amountOut). + // Drift means the call ABI differs from the on-chain contract → + // ethers decodes garbage from callStatic. + assert.match(SRC, + /function quoteExactInputSingle\(address tokenIn, address tokenOut, uint256 amountIn, uint160 sqrtPriceLimitX96\) external returns \(uint256 amountOut\)/, + `QUOTER_ABI signature drifted`); +}); + +test('POOL_ABI — globalState 7-tuple shape (price, tick, fee, ...)', () => { + // Pinned: globalState returns (uint160 price, int24 tick, uint16 fee, + // uint16 timepointIndex, uint16 communityFeeToken0, uint16 communityFeeToken1, bool unlocked). + // Caller reads .price, .tick, .fee, etc. Drift means undefined + // properties or off-by-one in tuple index. + assert.match(SRC, + /function globalState\(\) external view returns \(uint160 price, int24 tick, uint16 fee, uint16 timepointIndex, uint16 communityFeeToken0, uint16 communityFeeToken1, bool unlocked\)/, + `POOL_ABI globalState shape drifted`); +}); + +test('POOL_ABI — has liquidity(), token0(), token1() getters', () => { + assert.match(SRC, /function liquidity\(\) external view returns \(uint128\)/); + assert.match(SRC, /function token0\(\) external view returns \(address\)/); + assert.match(SRC, /function token1\(\) external view returns \(address\)/); +}); + +test('ERC20_ABI — decimals + symbol getters', () => { + assert.match(SRC, /function decimals\(\) external view returns \(uint8\)/); + assert.match(SRC, /function symbol\(\) external view returns \(string\)/); +}); + +// --------------------------------------------------------------------------- +// getAlgebraQuote — callStatic + sqrtPriceLimitX96=0 + error parsing +// --------------------------------------------------------------------------- + +test('source — getAlgebraQuote uses callStatic (NOT direct call)', () => { + // Pinned: a regression to direct call would attempt a real signed + // transaction — burns gas and requires a signer. + assert.match(SRC, + /quoterContract\.callStatic\.quoteExactInputSingle\(/, + `must use callStatic — direct call burns gas and requires signer`); +}); + +test('source — getAlgebraQuote passes sqrtPriceLimitX96 = 0 (no price limit)', () => { + // Pinned: 0 means "no price limit" in Algebra. A regression to + // some non-zero literal would silently cap the swap or hit "AS". + assert.match(SRC, + /amountIn,\s*\n?\s*0\s*\/\/.*sqrtPriceLimitX96/, + `sqrtPriceLimitX96 must be 0 (no price limit) with the explanatory comment`); +}); + +test('source — error remapping: LOK → "Pool is locked"', () => { + assert.match(SRC, + /error\.message\?\.includes\(['"]LOK['"]\)[\s\S]*?throw new Error\(['"]Pool is locked['"]\)/, + `LOK → "Pool is locked" remap drifted`); +}); + +test('source — error remapping: IIA → "Insufficient input amount"', () => { + assert.match(SRC, + /error\.message\?\.includes\(['"]IIA['"]\)[\s\S]*?throw new Error\(['"]Insufficient input amount['"]\)/, + `IIA → "Insufficient input amount" remap drifted`); +}); + +test('source — error remapping: AS → "Price limit reached"', () => { + assert.match(SRC, + /error\.message\?\.includes\(['"]AS['"]\)[\s\S]*?throw new Error\(['"]Price limit reached['"]\)/, + `AS → "Price limit reached" remap drifted`); +}); + +test('source — unknown errors re-thrown unchanged (no swallow)', () => { + // Pinned: a `throw error;` at the bottom of the catch ensures + // unknown errors propagate. A regression that returns null/0 + // would silently produce wrong quotes. + assert.match(SRC, + /throw\s+error\s*;?\s*\}\s*\}/, + `unknown errors must be re-thrown (not swallowed/returned)`); +}); + +// --------------------------------------------------------------------------- +// sqrtPriceX96ToPrice — Algebra V3 standard formula +// --------------------------------------------------------------------------- + +test('sqrtPriceX96ToPrice — sqrt = 2^96 → price = 1', () => { + const r = sqrtPriceX96ToPrice((2n ** 96n).toString()); + assert.equal(r, 1, `sqrt=2^96 must yield price=1 (sqrt²/Q192 = 1)`); +}); + +test('sqrtPriceX96ToPrice — sqrt = 2 * 2^96 → price = 4', () => { + const r = sqrtPriceX96ToPrice((2n * (2n ** 96n)).toString()); + assert.equal(r, 4); +}); + +test('sqrtPriceX96ToPrice — null/invalid input returns null (not throw)', () => { + // Pinned: callers may pass undefined when pool data not yet loaded. + // try/catch returns null — a regression to throw would crash UI. + assert.equal(sqrtPriceX96ToPrice(null), null); + assert.equal(sqrtPriceX96ToPrice('not a number'), null); +}); + +test('sqrtPriceX96ToPrice — non-negative for any valid sqrt (square invariant)', () => { + for (const exp of [0, 48, 96, 144, 160]) { + const r = sqrtPriceX96ToPrice((2n ** BigInt(exp)).toString()); + assert.ok(r >= 0, `sqrt=2^${exp} produced negative price ${r}`); + } +}); + +test('sqrtPriceX96ToPrice — monotonic in sqrtPrice', () => { + const a = sqrtPriceX96ToPrice((1n << 96n).toString()); + const b = sqrtPriceX96ToPrice((2n << 96n).toString()); + const c = sqrtPriceX96ToPrice((10n << 96n).toString()); + assert.ok(a < b && b < c); +}); + +test('source — sqrtPriceX96ToPrice formula matches canonical sqrt²/Q192', () => { + // Cross-pin: the inline math in getAlgebraQuoteWithSlippage uses + // the same formula. Drift between the two would silently differ + // from the canonical sqrt-price-x96.test.mjs pin. + assert.match(SRC, + /sqrtPriceSquared\s*=\s*sqrtPriceX96\.mul\(sqrtPriceX96\)/, + `inline rawPoolPrice formula must use sqrt.mul(sqrt) (NOT sqrt.pow(2) — different gas semantics)`); + assert.match(SRC, + /Q192\s*=\s*Q96\.mul\(Q96\)/, + `Q192 must be Q96.mul(Q96)`); +}); + +// --------------------------------------------------------------------------- +// Slippage math — default 50 bps + (10000 - bps) / 10000 +// --------------------------------------------------------------------------- + +test('source — getAlgebraQuoteWithSlippage default slippageBps = 50 (0.5%)', () => { + assert.match(SRC, + /slippageBps\s*=\s*50,?\s*\n/, + `default slippageBps drifted from 50 (0.5%) — too low → quote rejected; too high → user gets less`); +}); + +test('source — slippage formula: amountOut * (10000 - bps) / 10000', () => { + // Cross-pin against canonical slippage-math.test.mjs. Same algorithm, + // different module — drift here means swap quotes diverge from + // canonical UI math. + assert.match(SRC, + /slippageFactor\s*=\s*ethers\.BigNumber\.from\(10000\s*-\s*slippageBps\)/, + `slippageFactor formula drifted from BigNumber.from(10000 - bps)`); + assert.match(SRC, + /amountOutWei\.mul\(slippageFactor\)\.div\(10000\)/, + `minAmountOut formula drifted from amountOut.mul(factor).div(10000)`); +}); + +test('slippage — applySlippageBps spec mirror: 0 bps → identity', () => { + const out = '1000000000000000000'; + assert.equal(applySlippageBps(out, 0), BigInt(out)); +}); + +test('slippage — applySlippageBps: 50 bps (0.5%) on 1e18 → 0.995e18', () => { + const r = applySlippageBps((10n ** 18n).toString(), 50); + const expected = (10n ** 18n) * 9950n / 10000n; + assert.equal(r, expected); +}); + +test('slippage — applySlippageBps non-increasing as bps grows', () => { + const out = '1000000000000000000'; + let prev = BigInt(out) + 1n; + for (const bps of [0, 10, 50, 100, 500]) { + const r = applySlippageBps(out, bps); + assert.ok(r < prev, + `monotonicity broken: bps=${bps} produced minOut=${r} ≥ prev=${prev}`); + prev = r; + } +}); + +// --------------------------------------------------------------------------- +// Direction detection — toLowerCase comparison +// --------------------------------------------------------------------------- + +test('detectDirection — token0→token1 swap detected', () => { + const r = detectDirection('0xAA', '0xBB', '0xAA', '0xBB'); + assert.equal(r.isToken0ToToken1, true); + assert.equal(r.isToken1ToToken0, false); +}); + +test('detectDirection — token1→token0 swap detected (reverse)', () => { + const r = detectDirection('0xBB', '0xAA', '0xAA', '0xBB'); + assert.equal(r.isToken0ToToken1, false); + assert.equal(r.isToken1ToToken0, true); +}); + +test('detectDirection — case-insensitive (lowercase comparison)', () => { + // Pinned: ethers returns checksum addresses, but the input may be + // lower or mixed case. A regression to strict equality would + // silently mis-detect direction → wrong price inversion. + const r = detectDirection('0xaaaaaa', '0xBBBBBB', '0xAAAAAA', '0xbbbbbb'); + assert.equal(r.isToken0ToToken1, true, + `direction detection must be case-insensitive`); +}); + +test('source — token addresses lowercased BEFORE comparison', () => { + // Pinned all four lowercases. + assert.match(SRC, /token0Lower\s*=\s*token0Address\.toLowerCase\(\)/); + assert.match(SRC, /token1Lower\s*=\s*token1Address\.toLowerCase\(\)/); + assert.match(SRC, /tokenInLower\s*=\s*tokenIn\.toLowerCase\(\)/); + assert.match(SRC, /tokenOutLower\s*=\s*tokenOut\.toLowerCase\(\)/); +}); + +// --------------------------------------------------------------------------- +// Price inversion logic — currency/company orientation +// --------------------------------------------------------------------------- + +test('source — shouldInvertPrice = true when token0 is currency', () => { + // Pinned: rawPoolPrice = token1/token0. We want to display + // currency/company. So: + // token0=currency, token1=company → raw is company/currency → INVERT. + // token0=company, token1=currency → raw is currency/company → KEEP. + assert.match(SRC, + /if\s*\(token0IsCurrency\)\s*\{[\s\S]*?shouldInvertPrice\s*=\s*true[\s\S]*?\}\s*else\s*\{[\s\S]*?shouldInvertPrice\s*=\s*false/, + `inversion-by-token-orientation logic drifted`); +}); + +test('source — currentPrice = shouldInvertPrice ? 1/raw : raw', () => { + assert.match(SRC, + /currentPrice\s*=\s*shouldInvertPrice\s*\?\s*\(1\s*\/\s*rawPoolPrice\)\s*:\s*rawPoolPrice/, + `currentPrice inversion shape drifted`); +}); + +test('source — executionPrice for currency→company swap is INVERTED', () => { + // Pinned: when buying company with currency, amountOut/amountIn = + // company/currency. We want to display currency/company → invert. + // A regression here = wrong execution price displayed. + assert.match(SRC, + /!tokenInIsCompany\s*&&\s*tokenOutIsCompany[\s\S]*?executionPrice\s*=\s*1\s*\/\s*rawExecutionPrice/, + `currency→company execution price must be 1/raw`); +}); + +test('source — executionPrice for company→currency swap is DIRECT', () => { + assert.match(SRC, + /tokenInIsCompany\s*&&\s*!tokenOutIsCompany[\s\S]*?executionPrice\s*=\s*rawExecutionPrice/, + `company→currency execution price must be raw (not inverted)`); +}); + +// --------------------------------------------------------------------------- +// Performance / gas contract pins +// --------------------------------------------------------------------------- + +test('source — gasEstimate hardcoded at "400000" with comment about ~300k typical', () => { + // Pinned: comment says Algebra swaps typically use ~300k gas; + // 400k provides a buffer. A regression to a much higher value + // surfaces as confusing wallet-prompt UX. + assert.match(SRC, + /gasEstimate:\s*['"]400000['"]/, + `gasEstimate drifted from "400000"`); +}); + +test('source — rpcCalls: 4 performance contract pin (the file\'s reason for existing)', () => { + // The whole point of this file is "1-4 RPC calls per quote" + // (vs 420+ from @swapr/sdk). Pinned because a regression that + // re-introduces a loop or extra fetch would silently re-introduce + // the latency this file was created to fix. + assert.match(SRC, + /rpcCalls:\s*4/, + `rpcCalls drifted from 4 — may indicate extra RPC calls slipped in`); +}); + +test('source — opening docstring references the "420+ RPC calls" optimization narrative', () => { + // Pinned the historical context. If the comment is removed, future + // contributors lose the WHY behind the file's architecture. + assert.match(SRC, + /420\+/, + `module docstring no longer mentions the 420+ RPC call problem this file fixed`); +}); diff --git a/auto-qa/tests/asset-refs.test.mjs b/auto-qa/tests/asset-refs.test.mjs new file mode 100644 index 0000000..35ce83d --- /dev/null +++ b/auto-qa/tests/asset-refs.test.mjs @@ -0,0 +1,131 @@ +/** + * Asset reference baseline test (auto-qa). + * + * Walks src/ for `/assets/...` references and asserts each one + * resolves to a file in public/. Catches a class of "broken image" + * deploys where someone renames an asset without updating callers. + * + * Per the auto-qa directive (do not fix production bugs in this loop), + * the 6 currently-broken refs are pinned in BASELINE_BROKEN_REFS. + * Any NEW broken ref fails the test loudly. Any time someone fixes + * one from the baseline, the test prompts them to remove it from the + * baseline (so the count keeps ratcheting down). + * + * Excluded callers (Storybook / docs / template): + * - src/stories/Configure.mdx + * - src/config/README.md + * - src/config/markets-example.js + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = new URL('../../', import.meta.url); +const SRC_DIR = fileURLToPath(new URL('src', REPO_ROOT)); +const PUBLIC_DIR = fileURLToPath(new URL('public', REPO_ROOT)); + +const EXCLUDED_CALLERS = new Set([ + 'src/stories/Configure.mdx', // Storybook welcome page (not shipped) + 'src/config/README.md', // documentation + 'src/config/markets-example.js', // example template, not imported +]); + +// Refs that are broken today but cannot be fixed in this loop (production +// is off-limits). When a fix lands and one of these refs starts resolving, +// remove it from the baseline so the count keeps ratcheting down. +const BASELINE_BROKEN_REFS = new Set([ + '/assets/default-company-logo.png', // src/utils/imageUtils.js + '/assets/default-logo.png', // EventHighlightCard.jsx + '/assets/fallback-company.png', // ResolvedEventsDataTransformer.jsx + 2 more + '/assets/kleros-proposal-1.png', // src/config/mapped-seo.json + '/assets/market-logo.svg', // MarketPage.jsx + '/assets/starbucks-market-card-1.png', // src/config/mapped-seo.json +]); + +function walk(dir, results = []) { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const st = statSync(full); + if (st.isDirectory()) walk(full, results); + else if (/\.(jsx?|tsx?|mdx?|json)$/.test(name)) results.push(full); + } + return results; +} + +function extractAssetRefs() { + const refs = new Map(); // ref -> Set + const re = /\/assets\/[a-zA-Z0-9._\-/]+\.(png|jpg|jpeg|webp|svg|gif|ico)/g; + for (const file of walk(SRC_DIR)) { + const rel = relative(fileURLToPath(REPO_ROOT), file); + if (EXCLUDED_CALLERS.has(rel)) continue; + const src = readFileSync(file, 'utf8'); + const matches = src.match(re) || []; + for (const m of matches) { + if (!refs.has(m)) refs.set(m, new Set()); + refs.get(m).add(rel); + } + } + return refs; +} + +function assetExists(ref) { + // ref looks like "/assets/foo/bar.png"; strip leading slash. + const rel = ref.replace(/^\//, ''); + try { + statSync(join(fileURLToPath(REPO_ROOT), 'public', rel)); + return true; + } catch { return false; } +} + +const ALL_REFS = extractAssetRefs(); +const BROKEN_REFS = [...ALL_REFS.keys()].filter(r => !assetExists(r)); +const NEW_BROKEN = BROKEN_REFS.filter(r => !BASELINE_BROKEN_REFS.has(r)); +const FIXED_FROM_BASELINE = [...BASELINE_BROKEN_REFS].filter(r => assetExists(r)); + +test('asset-refs — extractor finds at least 30 asset refs in production code', () => { + // Sanity check: if this number drops to 0 the regex is broken. + assert.ok(ALL_REFS.size >= 30, + `extractor found only ${ALL_REFS.size} refs — regex / walker likely broken`); +}); + +test('asset-refs — public/assets directory exists and is populated', () => { + const items = readdirSync(PUBLIC_DIR + '/assets'); + assert.ok(items.length > 50, + `public/assets has ${items.length} items — directory likely truncated`); +}); + +test('asset-refs — no NEW broken /assets/ refs since baseline', () => { + if (NEW_BROKEN.length === 0) return; + const lines = NEW_BROKEN.map(r => { + const callers = [...ALL_REFS.get(r)].slice(0, 3).join(', '); + return ` ${r} ← ${callers}`; + }).join('\n'); + assert.fail( + `${NEW_BROKEN.length} new broken /assets/ ref(s) found beyond the baseline:\n${lines}\n\n` + + `Either add the missing file under public/ OR update the calling code.\n` + + `If genuinely intentional, add the ref to BASELINE_BROKEN_REFS in this test ` + + `(but please leave a comment explaining why).` + ); +}); + +test('asset-refs — every BASELINE_BROKEN_REFS entry is still actually broken', () => { + if (FIXED_FROM_BASELINE.length === 0) return; + assert.fail( + `${FIXED_FROM_BASELINE.length} BASELINE_BROKEN_REFS entries now resolve in public/:\n` + + FIXED_FROM_BASELINE.map(r => ` ${r}`).join('\n') + + `\n\nNice — please REMOVE these from BASELINE_BROKEN_REFS in this test ` + + `so the ratchet keeps tightening.` + ); +}); + +test('asset-refs — baseline broken count is exactly what we expect', () => { + // Snapshot ratchet — surfaces if the baseline gets edited without + // updating this number (the dual-test above would catch removals, + // but this catches manual baseline-list additions). + assert.equal(BASELINE_BROKEN_REFS.size, 6, + `BASELINE_BROKEN_REFS size changed from 6 to ${BASELINE_BROKEN_REFS.size}. ` + + `If you added entries, also bump this number; if you removed entries (a fix landed), bump down.`); +}); diff --git a/auto-qa/tests/best-rpc-cache.test.mjs b/auto-qa/tests/best-rpc-cache.test.mjs new file mode 100644 index 0000000..b064972 --- /dev/null +++ b/auto-qa/tests/best-rpc-cache.test.mjs @@ -0,0 +1,282 @@ +/** + * getBestRpc cache helpers spec mirror (auto-qa). + * + * Pins src/utils/getBestRpc.js — the chain-aware RPC selector that + * powers any code path needing a fast working RPC. Three layers: + * + * 1. RPC_LISTS — hardcoded URL lists per chain. HTTPS-only, dedup, + * non-empty. A regression that empties one would fall through to + * `throw new Error("No RPC endpoints configured for chain ...")`. + * + * 2. Constants — RPC_TIMEOUT_MS (5s), CACHE_DURATION_MS (5min), + * MAX_CACHED_RPC_COUNT (3). Drift here changes user-visible + * latency / staleness silently. + * + * 3. normalizeCacheEntry — back-compat shim. Older builds wrote + * { url, timestamp } (single-url shape); the new code reads + * { urls, timestamp } (array shape). The shim must preserve the + * old entries so warm caches survive a rolling deploy. + * + * 4. The LRU rotation inside tryCachedRpcs — when a cached candidate + * succeeds, it must move to FRONT, dedupe, and clip to + * MAX_CACHED_RPC_COUNT. A regression that drops the dedupe would + * let the same URL accumulate (and crowd out the others). + * + * The async path (testRpc, getBestRpc) is NOT mirrored — it does + * fetch() + console.log + setTimeout. That's an integration concern. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const SRC = readFileSync( + new URL('../../src/utils/getBestRpc.js', import.meta.url), + 'utf8', +); + +// --- spec mirror of normalizeCacheEntry (pure) --- +function normalizeCacheEntry(entry) { + if (!entry) return null; + if (Array.isArray(entry.urls)) { + return entry; + } + if (entry.url) { + return { + urls: [entry.url], + timestamp: entry.timestamp || Date.now(), + }; + } + return null; +} + +// --- spec mirror of the LRU rotation inside tryCachedRpcs --- +// When candidateUrl succeeds: move to front, dedupe, clip to max. +function rotateOnHit(urls, candidateUrl, maxCount) { + const deduped = urls.filter(url => url !== candidateUrl); + return [candidateUrl, ...deduped].slice(0, maxCount); +} + +// --------------------------------------------------------------------------- +// RPC_LISTS — pinned from source text +// --------------------------------------------------------------------------- + +function extractRpcList(chainId) { + // Find the RPC_LISTS object body, then find the chainId block within. + const block = SRC.match(/RPC_LISTS\s*=\s*\{([\s\S]*?)\n\};/); + assert.ok(block, 'RPC_LISTS object not found'); + // Lines like ` 100: [ ... ]` — pull the array following `:`. + const re = new RegExp(`${chainId}\\s*:\\s*\\[([\\s\\S]*?)\\]`); + const m = block[1].match(re); + if (!m) return null; + return [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1]); +} + +const ETH_RPCS = extractRpcList(1); +const GNOSIS_RPCS = extractRpcList(100); + +test('RPC_LISTS — chain 1 (Ethereum) has >= 3 entries, all HTTPS, deduped', () => { + assert.ok(ETH_RPCS, 'chain 1 RPC list not extractable'); + assert.ok(ETH_RPCS.length >= 3, + `chain 1 has only ${ETH_RPCS.length} RPCs — fallback loses meaning below 3`); + for (const url of ETH_RPCS) { + assert.match(url, /^https:\/\//, + `chain 1 contains non-HTTPS RPC: ${url}`); + } + assert.equal(new Set(ETH_RPCS).size, ETH_RPCS.length, + `chain 1 RPC list contains duplicates`); +}); + +test('RPC_LISTS — chain 100 (Gnosis) has >= 3 entries, all HTTPS, deduped', () => { + assert.ok(GNOSIS_RPCS, 'chain 100 RPC list not extractable'); + assert.ok(GNOSIS_RPCS.length >= 3, + `chain 100 has only ${GNOSIS_RPCS.length} RPCs`); + for (const url of GNOSIS_RPCS) { + assert.match(url, /^https:\/\//, + `chain 100 contains non-HTTPS RPC: ${url}`); + } + assert.equal(new Set(GNOSIS_RPCS).size, GNOSIS_RPCS.length, + `chain 100 RPC list contains duplicates`); +}); + +test('RPC_LISTS — chain 100 includes the canonical rpc.gnosischain.com', () => { + // Pinned because it's the chain's own official endpoint and survives + // any third-party outage. + assert.ok(GNOSIS_RPCS.includes('https://rpc.gnosischain.com'), + `chain 100 RPC list missing canonical https://rpc.gnosischain.com`); +}); + +// --------------------------------------------------------------------------- +// Constants — pinned from source text +// --------------------------------------------------------------------------- + +test('RPC_TIMEOUT_MS — pinned at 5000ms (5 seconds)', () => { + const m = SRC.match(/RPC_TIMEOUT_MS\s*=\s*(\d+)/); + assert.ok(m, 'RPC_TIMEOUT_MS not found'); + assert.equal(parseInt(m[1]), 5000, + `RPC_TIMEOUT_MS drifted from 5000ms — too low fails fast on slow networks; ` + + `too high stalls the user before fallback kicks in.`); +}); + +test('CACHE_DURATION_MS — pinned at 5 minutes', () => { + // The expression is `5 * 60 * 1000` — match the parts. + const m = SRC.match(/CACHE_DURATION_MS\s*=\s*5\s*\*\s*60\s*\*\s*1000/); + assert.ok(m, + `CACHE_DURATION_MS drifted from 5*60*1000 (5 min). ` + + `Shortening means more cold probes; lengthening means stale cached RPCs ` + + `that may have gone down.`); +}); + +test('MAX_CACHED_RPC_COUNT — pinned at 3 (top-3 fastest kept warm)', () => { + const m = SRC.match(/MAX_CACHED_RPC_COUNT\s*=\s*(\d+)/); + assert.ok(m, 'MAX_CACHED_RPC_COUNT not found'); + assert.equal(parseInt(m[1]), 3, + `MAX_CACHED_RPC_COUNT changed — affects fallback breadth in cached path. ` + + `1 = single-point-of-failure; >5 = cache stale URLs longer than helpful.`); +}); + +// --------------------------------------------------------------------------- +// normalizeCacheEntry — null/empty inputs +// --------------------------------------------------------------------------- + +test('normalizeCacheEntry — null entry returns null', () => { + assert.equal(normalizeCacheEntry(null), null); +}); + +test('normalizeCacheEntry — undefined entry returns null', () => { + assert.equal(normalizeCacheEntry(undefined), null); +}); + +test('normalizeCacheEntry — empty object returns null (no urls, no url)', () => { + assert.equal(normalizeCacheEntry({}), null); +}); + +// --------------------------------------------------------------------------- +// normalizeCacheEntry — modern shape (urls array) is returned unchanged +// --------------------------------------------------------------------------- + +test('normalizeCacheEntry — modern shape returned by identity (not copy)', () => { + // Identity matters: callers mutate cacheEntry in-place to update + // urls/timestamp on hit. A defensive copy here would silently + // discard those mutations. + const entry = { urls: ['https://a'], timestamp: 12345 }; + assert.equal(normalizeCacheEntry(entry), entry, + `modern shape MUST be returned by identity (callers mutate in place)`); +}); + +test('normalizeCacheEntry — modern shape with empty urls array still returned', () => { + // Empty-but-array means "we tried and nothing worked" — distinct + // from missing. Caller checks `urls.length` separately. + const entry = { urls: [], timestamp: 12345 }; + assert.equal(normalizeCacheEntry(entry), entry); +}); + +// --------------------------------------------------------------------------- +// normalizeCacheEntry — legacy shape (single url) gets upgraded +// --------------------------------------------------------------------------- + +test('normalizeCacheEntry — legacy { url, timestamp } upgraded to { urls: [url], timestamp }', () => { + // Pinned: this back-compat shim is what lets a rolling deploy keep + // the warm cache. Drop it and every user pays a cold-probe penalty + // on the deploy boundary. + const legacy = { url: 'https://a', timestamp: 12345 }; + const r = normalizeCacheEntry(legacy); + assert.deepEqual(r, { urls: ['https://a'], timestamp: 12345 }); +}); + +test('normalizeCacheEntry — legacy without timestamp gets a fresh Date.now()', () => { + // Defensive default: if a legacy entry somehow lost its timestamp, + // we treat it as fresh rather than letting it expire instantly. + const before = Date.now(); + const r = normalizeCacheEntry({ url: 'https://a' }); + const after = Date.now(); + assert.deepEqual(r.urls, ['https://a']); + assert.ok(r.timestamp >= before && r.timestamp <= after, + `timestamp must be set to ~Date.now() when legacy entry lacks one`); +}); + +// --------------------------------------------------------------------------- +// LRU rotation — successful URL moves to front, dedupes, clips to max +// --------------------------------------------------------------------------- + +test('rotateOnHit — candidate already at front: stays at front, no duplicates', () => { + const r = rotateOnHit(['a', 'b', 'c'], 'a', 3); + assert.deepEqual(r, ['a', 'b', 'c']); +}); + +test('rotateOnHit — candidate in middle: moves to front, others preserve order', () => { + const r = rotateOnHit(['a', 'b', 'c'], 'b', 3); + assert.deepEqual(r, ['b', 'a', 'c']); +}); + +test('rotateOnHit — candidate at end: moves to front', () => { + const r = rotateOnHit(['a', 'b', 'c'], 'c', 3); + assert.deepEqual(r, ['c', 'a', 'b']); +}); + +test('rotateOnHit — candidate not in list: prepended, list grows up to max', () => { + // This case can happen after an entry ages out of the array but + // the caller passes it back in (shouldn't really happen given the + // current code, but the function handles it). + const r = rotateOnHit(['a', 'b'], 'c', 3); + assert.deepEqual(r, ['c', 'a', 'b']); +}); + +test('rotateOnHit — clips to MAX_CACHED_RPC_COUNT when over capacity', () => { + // Pinned: bug would be "we keep 4 cached RPCs instead of 3" — silent + // regression that weakens the eviction guarantee. + const r = rotateOnHit(['a', 'b', 'c', 'd'], 'e', 3); + assert.deepEqual(r, ['e', 'a', 'b']); +}); + +test('rotateOnHit — dedupe does NOT add a duplicate when candidate appears twice in input', () => { + // Defensive: even if upstream wrote duplicates, a single rotation + // should de-dupe them. + const r = rotateOnHit(['a', 'b', 'a'], 'a', 3); + assert.deepEqual(r, ['a', 'b']); +}); + +// --------------------------------------------------------------------------- +// Source-text shape pins (catch silent refactors) +// --------------------------------------------------------------------------- + +test('getBestRpc — uses POST + eth_blockNumber for the probe (not GET / not chain_id)', () => { + // Pinned: an RPC test that uses GET would always fail on + // standards-compliant JSON-RPC endpoints. A test that uses + // `eth_chainId` would fail on chains that haven't replied to that + // method (rare but possible). `eth_blockNumber` is the canonical + // liveness probe. + assert.match(SRC, /method:\s*['"]POST['"]/, 'probe method must be POST'); + assert.match(SRC, /method:\s*['"]eth_blockNumber['"]/, + 'probe RPC method must be eth_blockNumber'); +}); + +test('getBestRpc — sets jsonrpc: "2.0" in the probe body', () => { + // Some RPC endpoints reject calls without the proper jsonrpc version. + assert.match(SRC, /jsonrpc:\s*['"]2\.0['"]/); +}); + +test('getBestRpc — uses AbortController + setTimeout for the timeout (not setTimeout-only)', () => { + // Pinned: a setTimeout-only timeout would race the request but not + // actually cancel it — fetch keeps the socket open. AbortController + // is the only way to actually free the resource on timeout. + assert.match(SRC, /AbortController/); + assert.match(SRC, /controller\.abort\(\)/); + assert.match(SRC, /signal:\s*controller\.signal/); +}); + +test('getBestRpc — sorts working RPCs by ascending latency', () => { + // Pinned the sort direction: descending would pick the SLOWEST + // working RPC — silent UX regression. + assert.match(SRC, /\.sort\(\(a,\s*b\)\s*=>\s*a\.latency\s*-\s*b\.latency\)/, + `working RPCs must be sorted ASC by latency (a.latency - b.latency)`); +}); + +test('getBestRpc — exports clearRpcCache, diagnoseRpcs, getRpcCacheStatus', () => { + // These are utilities used by debug pages / diagnostics. A refactor + // that drops the export breaks those callers silently (next build + // surfaces the error but only if the caller is in a tested path). + assert.match(SRC, /export\s+function\s+clearRpcCache/); + assert.match(SRC, /export\s+async\s+function\s+diagnoseRpcs/); + assert.match(SRC, /export\s+function\s+getRpcCacheStatus/); +}); diff --git a/auto-qa/tests/contract-addresses.test.mjs b/auto-qa/tests/contract-addresses.test.mjs new file mode 100644 index 0000000..5a738a3 --- /dev/null +++ b/auto-qa/tests/contract-addresses.test.mjs @@ -0,0 +1,175 @@ +/** + * Contract addresses lint (auto-qa). + * + * Walks src/components/futarchyFi/marketPage/constants/contracts.js and + * asserts every 0x-prefixed string follows shape rules. Catches a + * class of catastrophic bugs: + * + * - An address typo (truncated, extra digit, wrong case) silently + * breaks every transaction targeting that contract. + * - A merge accident concatenates two addresses into one. + * - A refactor swaps two addresses by name. + * + * Pinned shape invariants: + * - Address: 0x + exactly 40 hex chars + * - Hash / condition id: 0x + 64 hex chars (32 bytes) + * - Chain id / small constant: 0x + 1-8 hex chars + * - Everything else is suspicious + * + * Pinned specific values that are "external constants" — well-known + * addresses that must never drift: + * - REQUIRED_CHAIN_ID = '0x64' (Gnosis Chain = 100 in hex) + * - COW_SETTLEMENT_ADDRESS = canonical CoW Protocol settlement + * - WXDAI_ADDRESS = canonical wrapped xDAI on Gnosis + * + * Plus a count baseline so additions/removals are intentional. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const FILE = new URL( + '../../src/components/futarchyFi/marketPage/constants/contracts.js', + import.meta.url, +); + +const SRC = readFileSync(FILE, 'utf8'); + +// Extract every quoted "0x..." literal. Match in single-quoted strings only +// to avoid pulling 0x out of comments. The constants file uses single +// quotes consistently for these literals. +const HEX_LITERAL_RE = /'(0x[0-9a-fA-F]+)'/g; +const all = []; +let m; +while ((m = HEX_LITERAL_RE.exec(SRC)) !== null) all.push(m[1]); + +const addresses = all.filter(s => s.length === 42); +const non42 = all.filter(s => s.length !== 42); + +// --------------------------------------------------------------------------- +// Sanity / extractor checks +// --------------------------------------------------------------------------- + +test('contracts — extractor finds at least 10 0x-literals', () => { + assert.ok(all.length >= 10, + `extractor found only ${all.length} 0x-literals — regex broken or file shrunk dramatically`); +}); + +// --------------------------------------------------------------------------- +// Address shape — every 42-char 0x-literal must be all-hex +// --------------------------------------------------------------------------- + +test('contracts — every 42-char 0x-literal is valid hex (40 chars after 0x)', () => { + for (const a of addresses) { + assert.match(a, /^0x[0-9a-fA-F]{40}$/, + `address fails shape check: "${a}"`); + } +}); + +test('contracts — no address is the zero address (0x000…000)', () => { + // A literal zero-address constant is almost always a copy-paste bug. + const ZERO = '0x' + '0'.repeat(40); + for (const a of addresses) { + assert.notEqual(a.toLowerCase(), ZERO, + `zero address found in contracts.js — likely a placeholder left in by mistake`); + } +}); + +test('contracts — addresses do not contain repeated characters past plausibility', () => { + // Catches "0xaaaaaaa..." or "0xfffff..." style placeholders. + for (const a of addresses) { + const body = a.slice(2).toLowerCase(); + const allSameChar = /^(.)\1+$/.test(body); + assert.ok(!allSameChar, + `address "${a}" is all the same character — likely a placeholder`); + } +}); + +// --------------------------------------------------------------------------- +// Non-address 0x-literals (chain ids, etc.) — must be 1-8 hex chars +// --------------------------------------------------------------------------- + +test('contracts — non-address 0x-literals are recognized shapes (chain id / 32-byte hash)', () => { + // Allowed shapes: + // - chain id / small constant: 1-8 hex chars (e.g. '0x64' for Gnosis) + // - 32-byte hash / condition id: exactly 64 hex chars + // Anything else is suspicious — possibly an address that lost or gained chars. + for (const s of non42) { + const body = s.slice(2); + assert.match(body, /^[0-9a-fA-F]+$/, `non-address literal "${s}" has non-hex chars`); + const isSmall = body.length >= 1 && body.length <= 8; + const isHash = body.length === 64; + assert.ok(isSmall || isHash, + `0x literal "${s}" has ${body.length} hex chars — not 40 (address), 64 (hash), or 1-8 (chain id). ` + + `Possibly a typo where an address lost or gained chars.`); + } +}); + +// --------------------------------------------------------------------------- +// Pinned external constants — well-known values that MUST NOT drift +// --------------------------------------------------------------------------- + +test('contracts — REQUIRED_CHAIN_ID is exactly 0x64 (Gnosis = 100)', () => { + const m = SRC.match(/REQUIRED_CHAIN_ID\s*=\s*'(0x[0-9a-fA-F]+)'/); + assert.ok(m, 'REQUIRED_CHAIN_ID literal not found in source'); + assert.equal(m[1], '0x64', + `REQUIRED_CHAIN_ID drifted: got "${m[1]}". 0x64 = chain id 100 (Gnosis Chain). ` + + `If we intentionally moved off Gnosis, this is a major rollout — update the test.`); +}); + +test('contracts — COW_SETTLEMENT_ADDRESS is the canonical CoW Protocol address', () => { + // CoW's settlement contract is the same address on every chain it's deployed on. + const m = SRC.match(/COW_SETTLEMENT_ADDRESS\s*=\s*'(0x[0-9a-fA-F]+)'/); + assert.ok(m, 'COW_SETTLEMENT_ADDRESS not found'); + assert.equal(m[1], '0x9008D19f58AAbD9eD0D60971565AA8510560ab41', + `COW_SETTLEMENT_ADDRESS drifted from canonical CoW Protocol settlement`); +}); + +test('contracts — WXDAI_ADDRESS is the canonical wXDAI on Gnosis', () => { + const m = SRC.match(/WXDAI_ADDRESS\s*=\s*'(0x[0-9a-fA-F]+)'/); + assert.ok(m, 'WXDAI_ADDRESS not found'); + assert.equal(m[1], '0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d', + `WXDAI_ADDRESS drifted — wXDAI on Gnosis is fixed`); +}); + +test('contracts — VAULT_RELAYER_ADDRESS is the canonical CoW vault relayer', () => { + const m = SRC.match(/VAULT_RELAYER_ADDRESS\s*=\s*'(0x[0-9a-fA-F]+)'/); + assert.ok(m, 'VAULT_RELAYER_ADDRESS not found'); + assert.equal(m[1], '0xC92E8bdf79f0507f65a392b0ab4667716BFE0110', + `VAULT_RELAYER_ADDRESS drifted from canonical CoW Protocol vault relayer`); +}); + +// --------------------------------------------------------------------------- +// Counts — baseline so additions/removals are intentional +// --------------------------------------------------------------------------- + +test('contracts — address count is within expected range', () => { + // Count includes addresses inside the ABI strings (those are mostly + // event topics that look like 0x... but happen to be 64 chars — they + // get filtered out of `addresses`). Just pin the order of magnitude. + assert.ok(addresses.length >= 8 && addresses.length <= 50, + `address count is ${addresses.length} — outside expected range [8, 50]. ` + + `If you added/removed addresses intentionally, bump this range.`); +}); + +test('contracts — extractor finds the expected NAMED addresses', () => { + // Spot-check a few names that must always exist. If any of these + // disappears, the surrounding components are likely broken. + const REQUIRED_NAMES = [ + 'CONDITIONAL_TOKENS_ADDRESS', + 'WRAPPER_SERVICE_ADDRESS', + 'VAULT_RELAYER_ADDRESS', + 'COW_SETTLEMENT_ADDRESS', + 'FUTARCHY_ROUTER_ADDRESS', + 'BASE_CURRENCY_TOKEN_ADDRESS', + 'BASE_COMPANY_TOKEN_ADDRESS', + 'MARKET_ADDRESS', + 'WXDAI_ADDRESS', + 'REQUIRED_CHAIN_ID', + ]; + for (const name of REQUIRED_NAMES) { + assert.match(SRC, new RegExp(`export const ${name}\\s*=`), + `required export "${name}" missing from contracts.js`); + } +}); diff --git a/auto-qa/tests/dead-references.test.mjs b/auto-qa/tests/dead-references.test.mjs new file mode 100644 index 0000000..9079815 --- /dev/null +++ b/auto-qa/tests/dead-references.test.mjs @@ -0,0 +1,90 @@ +/** + * Dead-references lint test (auto-qa). + * + * Pins references that should not appear (tickspread.com, PR #43) and + * tracks the count of legacy references that DO still appear (supabase + * imports, PR #47's cleanup was partial). Same baseline pattern as + * the graphql-compat test. + * + * Why count rather than zero: + * PR #43 cleanup is complete — assert exact zero. + * PR #47 cleanup was partial — 5 files still import @supabase/supabase-js + * even though most should be using the subgraph fetcher. Per /loop + * directive we don't fix production; we record the baseline so any + * regression (more imports added) AND any progress (imports removed) + * trips the test. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); +const SRC = join(REPO_ROOT, 'src'); +const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', 'coverage']); +const EXTS = new Set(['.js', '.jsx', '.mjs', '.ts', '.tsx', '.css']); + +function* walk(dir) { + let entries; + try { entries = readdirSync(dir); } catch { return; } + for (const name of entries) { + if (SKIP_DIRS.has(name)) continue; + const full = join(dir, name); + let st; try { st = statSync(full); } catch { continue; } + if (st.isDirectory()) yield* walk(full); + else if (st.isFile()) yield full; + } +} + +function findHits(pattern) { + const re = new RegExp(pattern); + const hits = []; + for (const file of walk(SRC)) { + const ext = '.' + file.split('.').pop(); + if (!EXTS.has(ext)) continue; + let text; try { text = readFileSync(file, 'utf8'); } catch { continue; } + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (re.test(lines[i])) { + hits.push({ file: relative(REPO_ROOT, file), line: i + 1, text: lines[i].trim() }); + } + } + } + return hits; +} + +// ──────────────────────────────────────────────────────────────────────── +// PR #43 — Remove tickspread.com URL references (cleanup is complete) +// ──────────────────────────────────────────────────────────────────────── +test('PR #43 — no tickspread.com URLs remain in src/', () => { + const hits = findHits('tickspread\\.com'); + assert.equal(hits.length, 0, + `Found ${hits.length} tickspread.com references that shouldn't exist:\n${ + hits.map(h => ` ${h.file}:${h.line} ${h.text}`).join('\n') + }`); +}); + +// ──────────────────────────────────────────────────────────────────────── +// PR #47 — Remove dead Supabase code (cleanup is PARTIAL) +// +// Baseline = the count of imports as of this iteration. If new ones get +// added: test fails (regression). If real-fix work removes some: test +// fails (forces the baseline to be lowered, ratcheting the cleanup). +// ──────────────────────────────────────────────────────────────────────── +const SUPABASE_IMPORT_BASELINE = 10; + +test('PR #47 — supabase import count matches baseline', () => { + const hits = findHits("from\\s+['\"]@supabase/supabase-js['\"]"); + const msg = `Supabase imports remaining (${hits.length}):\n${ + hits.map(h => ` ${h.file}:${h.line}`).join('\n') + }`; + if (hits.length > SUPABASE_IMPORT_BASELINE) { + assert.fail(`REGRESSION: count rose from ${SUPABASE_IMPORT_BASELINE} to ${hits.length}.\n${msg}`); + } else if (hits.length < SUPABASE_IMPORT_BASELINE) { + assert.fail(`PROGRESS: count fell from ${SUPABASE_IMPORT_BASELINE} to ${hits.length} — update the baseline in this test (lower the constant) and the entry in PROGRESS.md.\n${msg}`); + } + assert.equal(hits.length, SUPABASE_IMPORT_BASELINE); +}); diff --git a/auto-qa/tests/extractor-sanity.test.mjs b/auto-qa/tests/extractor-sanity.test.mjs new file mode 100644 index 0000000..bd3e69c --- /dev/null +++ b/auto-qa/tests/extractor-sanity.test.mjs @@ -0,0 +1,81 @@ +/** + * Sanity test for auto-qa/tools/extract-graphql.mjs (auto-qa). + * + * This is the foundation for the schema-compat checker (highest-leverage + * tool in our backlog). The extractor will only be useful if it actually + * pulls every shipped GraphQL query out of src/. This test asserts that: + * + * 1. The extractor runs without crashing. + * 2. It finds at least N queries (we know we ship many). + * 3. It picks up *known* queries we authored in this session — these are + * checkpoints against accidentally narrowing the heuristic later. + * + * If the extractor regresses (e.g. someone tightens looksLikeGraphQL and + * loses entries), this test fails clearly. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EXTRACTOR = resolve(__dirname, '../tools/extract-graphql.mjs'); + +function runExtractor() { + const out = execFileSync('node', [EXTRACTOR], { + encoding: 'utf8', + cwd: resolve(__dirname, '../..'), + }); + return JSON.parse(out); +} + +test('extractor runs and emits an array of query records', () => { + const queries = runExtractor(); + assert.ok(Array.isArray(queries), 'output should be an array'); + assert.ok(queries.length > 10, + `expected >10 queries (we ship many); got ${queries.length}`); +}); + +test('every record has the expected shape', () => { + const queries = runExtractor(); + for (const q of queries) { + assert.equal(typeof q.file, 'string', 'file is string'); + assert.equal(typeof q.line, 'number', 'line is number'); + assert.ok(q.line >= 1, 'line is 1-indexed'); + assert.equal(typeof q.query, 'string', 'query is string'); + assert.ok(q.query.length > 0, 'query is non-empty'); + } +}); + +test('finds known queries authored in this session', () => { + const queries = runExtractor(); + const files = new Set(queries.map(q => q.file)); + + // Files we know contain GraphQL strings (rewritten in PRs #62, #63, #65). + const expected = [ + 'src/hooks/useSubgraphData.js', // PR #65 + 'src/hooks/usePoolData.js', // PR #65 + 'src/utils/subgraphTradesClient.js', // PR #63 + 'src/adapters/subgraphConfigAdapter.js',// PR #62 + 'src/hooks/useAggregatorProposals.js', // PR #64 area + 'src/utils/SubgraphBulkPriceFetcher.js',// PR #64 area + ]; + + for (const f of expected) { + assert.ok(files.has(f), + `extractor missed ${f} — heuristic may have regressed`); + } +}); + +test('finds at least one anonymous-shorthand query (no "query" keyword)', () => { + const queries = runExtractor(); + // Many queries are written as `{ pools(where: …) { … } }` — no keyword. + // The looksLikeGraphQL heuristic must keep handling this shape. + const anonymous = queries.filter(q => + q.query.startsWith('{') && !/^\{\s*query\b/.test(q.query) + ); + assert.ok(anonymous.length > 0, + 'expected at least one anonymous-shorthand query like `{ pools(...) { ... } }`'); +}); diff --git a/auto-qa/tests/footer-links.test.mjs b/auto-qa/tests/footer-links.test.mjs new file mode 100644 index 0000000..4f830b8 --- /dev/null +++ b/auto-qa/tests/footer-links.test.mjs @@ -0,0 +1,115 @@ +/** + * Footer NAV_LINKS structural lint (auto-qa). + * + * Pins the structural invariants of the Footer's NAV_LINKS array. Two + * concerns it catches: + * + * (a) Mis-shaped entries (missing label/href, or `external: true` on + * a path-relative href, which would render as a broken absolute + * link) + * (b) Documentation link target — currently the unstable external + * `https://docs.futarchy.fi`. PR #41 (open) replaces this with + * in-app `/documents` (alias `/docs`). The test accepts EITHER + * so it stays green across the transition, and pins which states + * are valid so a typo or accidental third value (`/doc`, + * `https://github.com/...`) surfaces immediately. + * + * Static-grep style — no import, since the Footer is a Next.js JSX file + * and node:test's strict ESM doesn't resolve extension-less relative + * imports the way Next does. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const FOOTER_PATH = new URL('../../src/components/common/Footer.jsx', import.meta.url); + +function parseNavLinks() { + const src = readFileSync(FOOTER_PATH, 'utf8'); + const m = src.match(/const NAV_LINKS = \[([\s\S]*?)\];/); + assert.ok(m, 'NAV_LINKS array not found in Footer.jsx — has the file been refactored?'); + + // Pull each `{ ... }` object literal out of the array body. Avoids + // pulling in a JS parser dep — the array shape is small and stable. + const entries = []; + const re = /\{\s*([^{}]+?)\s*\}/g; + let item; + while ((item = re.exec(m[1])) !== null) { + const obj = {}; + // Match key: value pairs. Handles single-quoted strings and bare + // booleans. + const pairRe = /(\w+)\s*:\s*('([^']*)'|true|false)/g; + let p; + while ((p = pairRe.exec(item[1])) !== null) { + obj[p[1]] = p[3] !== undefined ? p[3] + : p[2] === 'true' ? true + : p[2] === 'false' ? false : p[2]; + } + entries.push(obj); + } + return entries; +} + +const NAV_LINKS = parseNavLinks(); + +test('Footer parser — extracts at least one NAV_LINKS entry', () => { + assert.ok(NAV_LINKS.length > 0, + 'NAV_LINKS parsed as empty — the regex extractor is broken or the array is empty.'); +}); + +test('Footer — every NAV_LINKS entry has a label and an href', () => { + for (const e of NAV_LINKS) { + assert.ok(typeof e.label === 'string' && e.label.length > 0, + `entry missing label: ${JSON.stringify(e)}`); + assert.ok(typeof e.href === 'string' && e.href.length > 0, + `entry missing href: ${JSON.stringify(e)}`); + } +}); + +test('Footer — `external: true` entries have an absolute http(s) href', () => { + for (const e of NAV_LINKS) { + if (e.external === true) { + assert.ok(/^https?:\/\//.test(e.href), + `entry "${e.label}" is marked external but href is not absolute: ${e.href}`); + } + } +}); + +test('Footer — non-external entries have a path-relative href', () => { + for (const e of NAV_LINKS) { + if (e.external !== true) { + assert.ok(e.href.startsWith('/'), + `entry "${e.label}" is not marked external but href is not path-relative: ${e.href}. ` + + `If the link is external, set external: true.`); + } + } +}); + +test('PR #41 — Documentation link target is one of the accepted values', () => { + // Accepted set spans the pre- and post-PR-#41 worlds. If the link + // gets pointed at a third value (typo, accidental redirect to a + // GitHub URL, etc.) this test surfaces it. + const ACCEPTED_DOCS_HREFS = new Set([ + 'https://docs.futarchy.fi', // pre-PR-#41 (current) + '/documents', // post-PR-#41 + '/docs', // post-PR-#41 alias + ]); + const docs = NAV_LINKS.find(e => e.label === 'Documentation'); + assert.ok(docs, 'no NAV_LINKS entry with label "Documentation"'); + assert.ok(ACCEPTED_DOCS_HREFS.has(docs.href), + `Documentation href "${docs.href}" is not in the accepted set ` + + `[${[...ACCEPTED_DOCS_HREFS].join(', ')}]. ` + + `Either fix the link or add the new value to ACCEPTED_DOCS_HREFS in this test.`); +}); + +test('PR #41 — Status link is the canonical status URL', () => { + // Adjacent invariant — the same NAV_LINKS array also holds Status, + // which has historically been a deployment regression target. Pin it. + const status = NAV_LINKS.find(e => e.label === 'Status'); + assert.ok(status, 'no NAV_LINKS entry with label "Status"'); + assert.equal(status.href, 'https://status.futarchy.fi', + `Status link drifted from canonical URL; got "${status.href}"`); + assert.equal(status.external, true, + `Status link must be marked external: true`); +}); diff --git a/auto-qa/tests/format-number.test.mjs b/auto-qa/tests/format-number.test.mjs new file mode 100644 index 0000000..bfccb2f --- /dev/null +++ b/auto-qa/tests/format-number.test.mjs @@ -0,0 +1,135 @@ +/** + * formatNumber utility tests (auto-qa). + * + * Pins the three functions in src/utils/formatNumber.js. They format + * counts and percentages on the Companies card (Snapshot-style display). + * Not tied to a single PR, but defensive against subtle drift in the + * suffix rules — a regression that bumps "999" to "0.999k" or strips + * the % sign would slip past code review and only surface as an + * unreadable card. + * + * Spec mirrors src/utils/formatNumber.js. If that file is refactored, + * sync the helpers below. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// --- spec-mirror of src/utils/formatNumber.js --- + +function formatSnapshotNumber(num, decimals = 1) { + if (num >= 1000000) return (num / 1000000).toFixed(decimals) + 'M'; + if (num >= 1000) return (num / 1000).toFixed(decimals) + 'k'; + if (Number.isInteger(num)) return num.toString(); + return num.toFixed(decimals); +} + +function formatSnapshotPercentage(percentage) { + return percentage.toFixed(2) + '%'; +} + +function formatCount(count) { + if (count >= 1000) return formatSnapshotNumber(count, 1); + if (count < 1) return count.toFixed(3); + if (count < 100) return count.toFixed(2); + return count.toFixed(1); +} + +// --------------------------------------------------------------------------- +// formatSnapshotNumber — suffix selection rules +// --------------------------------------------------------------------------- + +test('formatSnapshotNumber — exact 1_000_000 uses M suffix', () => { + assert.equal(formatSnapshotNumber(1_000_000), '1.0M'); +}); + +test('formatSnapshotNumber — exact 1000 uses k suffix', () => { + assert.equal(formatSnapshotNumber(1000), '1.0k'); +}); + +test('formatSnapshotNumber — 999 stays as raw integer (no suffix)', () => { + // Boundary catch: a regression that flipped >= to > would yield "0.999k". + assert.equal(formatSnapshotNumber(999), '999'); +}); + +test('formatSnapshotNumber — 999_999 falls in the k range, not M', () => { + assert.equal(formatSnapshotNumber(999_999), '1000.0k'); +}); + +test('formatSnapshotNumber — integer < 1000 returns plain string with no decimals', () => { + assert.equal(formatSnapshotNumber(0), '0'); + assert.equal(formatSnapshotNumber(1), '1'); + assert.equal(formatSnapshotNumber(42), '42'); +}); + +test('formatSnapshotNumber — non-integer < 1000 uses default decimals=1', () => { + assert.equal(formatSnapshotNumber(0.5), '0.5'); + assert.equal(formatSnapshotNumber(3.14), '3.1'); +}); + +test('formatSnapshotNumber — decimals param overrides default precision', () => { + assert.equal(formatSnapshotNumber(1234, 0), '1k'); + assert.equal(formatSnapshotNumber(1234, 2), '1.23k'); + assert.equal(formatSnapshotNumber(2_500_000, 2), '2.50M'); +}); + +// --------------------------------------------------------------------------- +// formatSnapshotPercentage — fixed 2-decimal % format +// --------------------------------------------------------------------------- + +test('formatSnapshotPercentage — always 2 decimals + % sign', () => { + assert.equal(formatSnapshotPercentage(0), '0.00%'); + assert.equal(formatSnapshotPercentage(50), '50.00%'); + assert.equal(formatSnapshotPercentage(100), '100.00%'); + assert.equal(formatSnapshotPercentage(33.33333), '33.33%'); +}); + +test('formatSnapshotPercentage — preserves negative sign', () => { + assert.equal(formatSnapshotPercentage(-12.5), '-12.50%'); +}); + +test('formatSnapshotPercentage — rounds half-to-even per JS toFixed', () => { + // Pin actual JS behavior so a future re-implementation that switches + // rounding mode (e.g. to Math.round-based) surfaces immediately. + assert.equal(formatSnapshotPercentage(0.005), '0.01%'); // 0.005 -> "0.01" in V8 +}); + +// --------------------------------------------------------------------------- +// formatCount — three-zone variable precision (>=1000, <1, in-between) +// --------------------------------------------------------------------------- + +test('formatCount — count >= 1000 delegates to formatSnapshotNumber', () => { + assert.equal(formatCount(1500), '1.5k'); + assert.equal(formatCount(2_000_000), '2.0M'); +}); + +test('formatCount — count < 1 uses 3 decimals', () => { + assert.equal(formatCount(0), '0.000'); + assert.equal(formatCount(0.1), '0.100'); + assert.equal(formatCount(0.123456), '0.123'); +}); + +test('formatCount — count in [1, 100) uses 2 decimals', () => { + assert.equal(formatCount(1), '1.00'); + assert.equal(formatCount(42.567), '42.57'); + assert.equal(formatCount(99.999), '100.00'); // toFixed rounds — pin this +}); + +test('formatCount — count in [100, 1000) uses 1 decimal', () => { + assert.equal(formatCount(100), '100.0'); + assert.equal(formatCount(999.99), '1000.0'); // toFixed rounds even at boundary + assert.equal(formatCount(500.55), '500.6'); +}); + +// --------------------------------------------------------------------------- +// Boundary continuity — adjacent zones must produce sane transitions +// --------------------------------------------------------------------------- + +test('formatCount — 999.4 displays in the [100,1000) zone, not k zone', () => { + // 999.4 < 1000, so .toFixed(1) → "999.4" (not "999.4k") + assert.equal(formatCount(999.4), '999.4'); +}); + +test('formatCount — 1000 crosses into k zone (suffix appears)', () => { + assert.equal(formatCount(1000), '1.0k'); +}); diff --git a/auto-qa/tests/futarchy-quote-helper.test.mjs b/auto-qa/tests/futarchy-quote-helper.test.mjs new file mode 100644 index 0000000..e70d28d --- /dev/null +++ b/auto-qa/tests/futarchy-quote-helper.test.mjs @@ -0,0 +1,337 @@ +/** + * FutarchyQuoteHelper spec mirror (auto-qa). + * + * Pins src/utils/FutarchyQuoteHelper.js — the swap-quote helper that + * wraps the on-chain FutarchyArbitrageHelper contract via callStatic + * (eth_call simulation, no signature). Powers swap-modal price quotes + * everywhere in the UI. + * + * Five concerns: + * + * 1. HELPER_ADDRESS — pinned canonical Gnosis address. A typo + * silently routes every quote to a non-existent / wrong contract. + * 2. HELPER_ABI — exact tuple shape (6 fields). Drift in ordering + * or types means callStatic returns garbage that this code then + * "interprets" — silent quote corruption. + * 3. calculatePriceFromSqrt — Algebra V3 sqrtPriceX96 → price math, + * duplicated from utils/algebraQuoter.js style. Cross-checked + * against the canonical sqrtPriceX96ToPrice in sqrt-price-x96.test.mjs. + * 4. Slippage math — `slippageBps = round(slippage * 10000)`, + * `minReceive = out * (10000 - bps) / 10000`. Same algorithm as + * the canonical getMinReceive (slippage-math.test.mjs). Cross-pin + * so a divergent change here is visible. + * 5. Gas limit override (12_000_000) — pinned safety margin below + * Gnosis block gas limit (~17M). A regression to a higher value + * would cause public RPCs to reject the eth_call. + * + * The async getSwapQuote() function itself (network-bound) is NOT + * unit-tested — only its source-text branches. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const SRC = readFileSync( + new URL('../../src/utils/FutarchyQuoteHelper.js', import.meta.url), + 'utf8', +); + +// --- spec mirror of calculatePriceFromSqrt (pure number math) --- +function calculatePriceFromSqrt(sqrtPriceX96Str) { + const curr = Number(sqrtPriceX96Str) / (2 ** 96); + return curr * curr; +} + +// --- spec mirror of slippage math --- +function slippageMath(amountOutBigStr, slippagePercentage) { + // BigInt-safe mirror; the source uses ethers BigNumber but the math + // is integer / 10000. + const amountOut = BigInt(amountOutBigStr); + const slippageBps = Math.round(slippagePercentage * 10000); + const minReceive = (amountOut * BigInt(10000 - slippageBps)) / 10000n; + return { slippageBps, minReceive }; +} + +// --------------------------------------------------------------------------- +// HELPER_ADDRESS — canonical Gnosis FutarchyArbitrageHelper +// --------------------------------------------------------------------------- + +test('HELPER_ADDRESS — pinned to the canonical 0xe32bfb3 verified Gnosis contract', () => { + const m = SRC.match(/HELPER_ADDRESS\s*=\s*['"]([^'"]+)['"]/); + assert.ok(m, 'HELPER_ADDRESS not found'); + assert.equal(m[1], '0xe32bfb3DD8bA4c7F82dADc4982c04Afa90027EFb', + `HELPER_ADDRESS drifted from canonical Gnosis FutarchyArbitrageHelper. ` + + `Every swap quote in the UI calls this contract — drift = wrong contract = wrong quotes.`); +}); + +test('HELPER_ADDRESS — valid 0x + 40 hex chars (EVM address shape)', () => { + const m = SRC.match(/HELPER_ADDRESS\s*=\s*['"]([^'"]+)['"]/); + assert.match(m[1], /^0x[0-9a-fA-F]{40}$/, + `HELPER_ADDRESS shape invalid`); +}); + +test('HELPER_ADDRESS — preserves EIP-55 mixed-case checksum form', () => { + // Pinned: keeping the checksummed form (lowercase + uppercase mix) + // means a future programmatic check (ethers.utils.getAddress) won't + // throw. A regression to all-lowercase would still validate but + // loses the visual hint about checksum integrity. + const m = SRC.match(/HELPER_ADDRESS\s*=\s*['"]([^'"]+)['"]/); + const addr = m[1]; + assert.notEqual(addr, addr.toLowerCase(), + `HELPER_ADDRESS must preserve EIP-55 checksum case (mixed case)`); +}); + +// --------------------------------------------------------------------------- +// HELPER_ABI — exact tuple shape +// --------------------------------------------------------------------------- + +test('HELPER_ABI — declares simulateQuote with EXACT param signature', () => { + // Pinned: (address proposal, bool isYesPool, uint8 inputType, uint256 amountIn). + // Drift in arg order or types changes the call ABI and either + // silently mis-decodes data or reverts. callStatic surfaces neither + // cleanly — it's a tough class of bug. + assert.match(SRC, + /function simulateQuote\(address proposal, bool isYesPool, uint8 inputType, uint256 amountIn\)/, + `simulateQuote arg signature drift`); +}); + +test('HELPER_ABI — return tuple has exactly 6 fields in canonical order', () => { + // Pinned: (int256 amount0Delta, int256 amount1Delta, uint160 startSqrtPrice, + // uint160 endSqrtPrice, bytes debugReason, bool isToken0Outcome). + // The handler reads result.amount0Delta, .amount1Delta, .startSqrtPrice, + // .endSqrtPrice, .isToken0Outcome — all six fields are wired in. + assert.match(SRC, + /tuple\(int256 amount0Delta, int256 amount1Delta, uint160 startSqrtPrice, uint160 endSqrtPrice, bytes debugReason, bool isToken0Outcome\)/, + `simulateQuote return tuple shape drift`); +}); + +// --------------------------------------------------------------------------- +// Empty / invalid amount → null +// --------------------------------------------------------------------------- + +test('source — getSwapQuote returns null for empty/NaN/<=0 amount', () => { + // Pinned: the guard `if (!amount || isNaN(parseFloat(amount)) || + // parseFloat(amount) <= 0) return null;` short-circuits BEFORE the + // network call. A regression that drops any branch wastes an + // eth_call and surfaces a wrong-shaped rejection to the UI. + assert.match(SRC, + /if\s*\(\s*!amount\s*\|\|\s*isNaN\(parseFloat\(amount\)\)\s*\|\|\s*parseFloat\(amount\)\s*<=\s*0\s*\)\s*\{\s*return null\s*;?\s*\}/, + `empty/invalid-amount guard shape drifted`); +}); + +// --------------------------------------------------------------------------- +// inputType encoding — company → 0, currency → 1 +// --------------------------------------------------------------------------- + +test('source — inputType: isInputCompanyToken ? 0 : 1 (company=0, currency=1)', () => { + // Pinned: the contract enum encoding. Flipping these silently + // routes EVERY swap to the OTHER token side — buy becomes sell, + // sell becomes buy. Catastrophic and hard to detect from outside. + assert.match(SRC, + /inputType\s*=\s*isInputCompanyToken\s*\?\s*0\s*:\s*1/, + `inputType encoding drifted from "company=0, currency=1"`); +}); + +// --------------------------------------------------------------------------- +// Gas limit override — 12M (safe under Gnosis 17M block limit) +// --------------------------------------------------------------------------- + +test('source — gasLimit override pinned at 12_000_000 (safe under Gnosis ~17M block limit)', () => { + // Pinned: the comment in the source explains this — public RPCs + // reject eth_call with gas above the block limit. 12M leaves room. + // A regression to 20M would silently break quotes on public RPCs + // (works on local fork, fails in prod). + assert.match(SRC, + /gasLimit:\s*12_000_000/, + `gasLimit drifted from 12_000_000 — too high → public RPC rejects ` + + `("Block gas limit exceeded"); too low → simulation runs out of gas.`); +}); + +// --------------------------------------------------------------------------- +// calculatePriceFromSqrt — spec mirror + cross-pin +// --------------------------------------------------------------------------- + +test('calcPriceFromSqrt — square root invariant: price = (sqrt / 2^96)^2', () => { + // Pinned the formula. A regression to /2^192 (full Q-format) would + // be off by a factor of 2^96 — silent. + const sqrt = 2n ** 96n; // sqrt = 1 in raw price terms + const price = calculatePriceFromSqrt(sqrt.toString()); + // price = (2^96 / 2^96)^2 = 1 + assert.equal(price, 1); +}); + +test('calcPriceFromSqrt — sqrt = 2 * 2^96 → price = 4', () => { + const sqrt = 2n * (2n ** 96n); + const price = calculatePriceFromSqrt(sqrt.toString()); + assert.equal(price, 4); +}); + +test('calcPriceFromSqrt — output is non-negative for any input (square invariant)', () => { + // Probe a few sample sqrt values; price = (x/2^96)^2 ≥ 0 always. + for (const exp of [0, 48, 96, 144, 192]) { + const sqrt = 2n ** BigInt(exp); + const price = calculatePriceFromSqrt(sqrt.toString()); + assert.ok(price >= 0, `price for sqrt=2^${exp} was negative: ${price}`); + } +}); + +test('calcPriceFromSqrt — monotonic in sqrtPrice (larger sqrt → larger price)', () => { + const a = calculatePriceFromSqrt(((1n << 96n)).toString()); + const b = calculatePriceFromSqrt(((2n << 96n)).toString()); + const c = calculatePriceFromSqrt(((10n << 96n)).toString()); + assert.ok(a < b && b < c, `monotonicity broken: a=${a}, b=${b}, c=${c}`); +}); + +test('calcPriceFromSqrt — source uses Number() not BigInt for the math (precision tradeoff pinned)', () => { + // Pinned: the source comment acknowledges JS Number is double-precision + // (15-17 digits). For prices in normal ranges this is enough; for + // extreme prices this is a known precision tradeoff. A regression + // to BigInt-only would lose the fractional portion entirely. + assert.match(SRC, + /Number\(sqrtPriceStr\)\s*\/\s*\(\s*2\s*\*\*\s*96\s*\)/, + `calculatePriceFromSqrt formula drifted — must be Number(sqrt) / (2**96)`); +}); + +// --------------------------------------------------------------------------- +// Slippage math — same algorithm as canonical getMinReceive (cross-pin) +// --------------------------------------------------------------------------- + +test('slippage — slippageBps = round(slippagePercentage * 10000)', () => { + // 3% → 300 bps. Pinned as the universal slippage encoding in this + // codebase (matches slippage-math.test.mjs canonical mirror). + assert.equal(slippageMath('1000', 0.03).slippageBps, 300); + assert.equal(slippageMath('1000', 0.005).slippageBps, 50); +}); + +test('slippage — slippageBps rounds (e.g. 0.0001 → 1, 0.00005 → 1, 0.00004 → 0)', () => { + // Pinned because Math.round(0.5) = 1 (banker's rounding behavior). + // 0.00004 * 10000 = 0.4 → 0; 0.00005 * 10000 = 0.5 → 1. + assert.equal(slippageMath('1', 0.00005).slippageBps, 1); + assert.equal(slippageMath('1', 0.00004).slippageBps, 0); +}); + +test('slippage — minReceive = amountOut * (10000 - bps) / 10000 (BigInt-safe)', () => { + // Probed amount: 1e18 (1 ether scale); 3% slippage → 0.97 * 1e18. + const r = slippageMath((10n ** 18n).toString(), 0.03); + const expected = (10n ** 18n) * 9700n / 10000n; + assert.equal(r.minReceive, expected); +}); + +test('slippage — zero slippage returns amountOut unchanged', () => { + const out = '12345678901234567890'; + const r = slippageMath(out, 0); + assert.equal(r.minReceive, BigInt(out), + `zero slippage must produce identity (got ${r.minReceive})`); +}); + +test('slippage — minReceive is non-increasing as slippage grows', () => { + const out = '1000000000000000000'; + let prev = BigInt(out) + 1n; // sentinel + for (const slip of [0, 0.001, 0.01, 0.05, 0.1]) { + const r = slippageMath(out, slip); + assert.ok(r.minReceive < prev, + `monotonicity broken: slip=${slip} produced minReceive=${r.minReceive} ≥ prev=${prev}`); + prev = r.minReceive; + } +}); + +test('source — slippage math expression matches canonical getMinReceive shape', () => { + // Cross-pin: slippage-math.test.mjs covers the canonical helper. + // This file inlines the same formula. Drift here would diverge + // from the canonical UI math — silent quote-vs-execution mismatch. + assert.match(SRC, + /slippageBps\s*=\s*Math\.round\(slippagePercentage\s*\*\s*10000\)/, + `inline slippageBps formula drifted from canonical`); + assert.match(SRC, + /\.mul\(10000\s*-\s*slippageBps\)\.div\(10000\)/, + `inline minReceive formula drifted from canonical (out * (10000-bps) / 10000)`); +}); + +// --------------------------------------------------------------------------- +// Inversion logic — !isToken0Outcome → invert price (1/p) +// --------------------------------------------------------------------------- + +test('source — inverts BOTH currentPoolPrice AND priceAfterNum when !isToken0Outcome', () => { + // Pinned: a regression that inverts only one side would display + // the start/end prices in different units — silent UX corruption. + assert.match(SRC, + /currentPoolPrice\s*=\s*\(currentPoolPrice\s*>\s*0\)\s*\?\s*1\s*\/\s*currentPoolPrice\s*:\s*0/, + `currentPoolPrice inversion shape drifted`); + assert.match(SRC, + /priceAfterNum\s*=\s*\(priceAfterNum\s*>\s*0\)\s*\?\s*1\s*\/\s*priceAfterNum\s*:\s*0/, + `priceAfterNum inversion shape drifted`); +}); + +test('source — division-by-zero guard: invert only when current/price > 0', () => { + // Pinned because 1/0 = Infinity in JS, not throw. A regression + // that drops the guard returns Infinity to UI rendering code. + // Source uses `(x > 0) ? 1/x : 0`. + const inversionGuards = [...SRC.matchAll(/\(\s*\w+(?:PoolPrice|AfterNum)\s*>\s*0\s*\)\s*\?\s*1\s*\/\s*\w+/g)]; + assert.ok(inversionGuards.length >= 2, + `expected >=2 div-by-zero guards in inversion path; got ${inversionGuards.length}`); +}); + +// --------------------------------------------------------------------------- +// Output match logic — find the side that is NOT the input +// --------------------------------------------------------------------------- + +test('source — amountOut detection: matches input side, returns the OTHER', () => { + // Pinned: the contract returns two deltas (one positive, one + // negative). The input matches one. amountOut = the other. + // A regression that reverses this returns the input as output — + // catastrophic display bug. + assert.match(SRC, + /if\s*\(absD0\.eq\(amountBig\)\)\s*\{\s*amountOutBig\s*=\s*absD1[\s\S]*?\}\s*else\s*\{\s*amountOutBig\s*=\s*absD0/, + `amountOut detection logic drifted — must be: if absD0==input then amountOut=absD1 else amountOut=absD0`); +}); + +test('source — uses callStatic (eth_call simulation, NOT a real tx)', () => { + // Pinned: a regression to `await helper.simulateQuote(...)` (no + // callStatic) would attempt a real signed transaction — would + // require gas, fail without a signer, and on a signer would + // BURN GAS for a query. + assert.match(SRC, + /helper\.callStatic\.simulateQuote\(/, + `must use callStatic (NOT direct call — direct call would burn gas)`); +}); + +test('source — empty-/null-provider guard throws BEFORE constructing the contract', () => { + // Pinned: the `if (!provider) throw` runs at the top of the + // function. A regression that lets it through would surface as + // a confusing ethers error deeper in the stack. + assert.match(SRC, + /if\s*\(!provider\)\s*\{\s*throw\s+new\s+Error\(["']Provider required for getSwapQuote["']\)/, + `provider guard shape drifted`); +}); + +// --------------------------------------------------------------------------- +// Output shape — the returned object has all expected fields +// --------------------------------------------------------------------------- + +test('source — returned object includes all 9 documented fields', () => { + // Pinned: callers destructure these. A field rename here breaks + // the consumer silently (undefined vs missing key). + const fields = [ + 'expectedReceive', 'minReceive', 'slippagePct', + 'currentPoolPrice', 'priceAfter', 'executionPrice', + 'startSqrtPrice', 'endSqrtPrice', 'isInverted', + ]; + for (const f of fields) { + // Each field must appear as a key in the returned object literal. + assert.match(SRC, new RegExp(`${f}\\s*:`), + `returned object missing field "${f}"`); + } +}); + +test('source — raw.amountIn / raw.amountOut are STRINGS (not BigNumbers) for JSON safety', () => { + // Pinned: BigNumbers don't JSON-serialize cleanly. Returning them + // as strings ensures callers can safely round-trip through + // JSON.stringify (e.g. analytics, logs). + assert.match(SRC, + /amountIn:\s*amountBig\.toString\(\)/, + `raw.amountIn must be .toString() (string for JSON safety)`); + assert.match(SRC, + /amountOut:\s*amountOutBig\.toString\(\)/, + `raw.amountOut must be .toString() (string for JSON safety)`); +}); diff --git a/auto-qa/tests/image-utils.test.mjs b/auto-qa/tests/image-utils.test.mjs new file mode 100644 index 0000000..ddc06cb --- /dev/null +++ b/auto-qa/tests/image-utils.test.mjs @@ -0,0 +1,213 @@ +/** + * imageUtils tests (auto-qa). + * + * Pins src/utils/imageUtils.js — the company-logo fallback chain used + * across the Companies card, ResolvedEvents data transformer, and + * useAggregatorCompanies hook. The priority chain (image → logo → + * logo_url → generated avatar) is subtle enough that a refactor that + * reorders or short-circuits would silently degrade thousands of + * cards to UI-Avatar placeholders. + * + * Spec mirrors src/utils/imageUtils.js for the pure functions + * (getCompanyImage, generateFallbackImage, getInitials, + * generateColorFromId, generateColorFromString, getDefaultFallbackImage). + * The async DOM-dependent helpers (verifyImageUrl, getVerifiedCompanyImage, + * preloadImage) are skipped — they need a browser-like Image global. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// --- spec-mirror of src/utils/imageUtils.js (pure functions only) --- + +function getInitials(name) { + if (!name || typeof name !== 'string') return 'CO'; + return name.split(' ').filter(w => w.length > 0).map(w => w[0].toUpperCase()).join('').slice(0, 2); +} + +function generateColorFromId(id) { + const colors = ['4F46E5','7C3AED','DB2777','DC2626','059669','2563EB','EA580C','16A34A','9333EA','CA8A04']; + const numericId = typeof id === 'string' ? parseInt(id) || 0 : id; + return colors[numericId % colors.length]; +} + +function generateColorFromString(str) { + if (!str || typeof str !== 'string') return '6B7280'; + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + return Math.abs(hash).toString(16).slice(0, 6).padStart(6, '0'); +} + +function generateFallbackImage(name, id) { + const initials = getInitials(name); + const bgColor = generateColorFromId(id); + return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&size=200&background=${bgColor}&color=fff&bold=true&rounded=true`; +} + +function getDefaultFallbackImage() { + return 'https://ui-avatars.com/api/?name=CO&size=200&background=6B7280&color=fff&bold=true&rounded=true'; +} + +function getCompanyImage(companyData) { + if (!companyData) return getDefaultFallbackImage(); + if (companyData.image && companyData.image.trim() !== '') return companyData.image; + if (companyData.logo && companyData.logo.trim() !== '') return companyData.logo; + if (companyData.logo_url && companyData.logo_url.trim() !== '') return companyData.logo_url; + return generateFallbackImage(companyData.name || 'Unknown', companyData.id || 0); +} + +// --------------------------------------------------------------------------- +// getCompanyImage — priority chain +// --------------------------------------------------------------------------- + +test('getCompanyImage — null/undefined returns default UI-Avatars URL', () => { + assert.equal(getCompanyImage(null), getDefaultFallbackImage()); + assert.equal(getCompanyImage(undefined), getDefaultFallbackImage()); +}); + +test('getCompanyImage — image field wins over logo and logo_url', () => { + const got = getCompanyImage({ + image: 'https://example.com/img.png', + logo: 'https://example.com/logo.png', + logo_url: 'https://example.com/logo_url.png', + }); + assert.equal(got, 'https://example.com/img.png'); +}); + +test('getCompanyImage — logo wins over logo_url when image is absent', () => { + const got = getCompanyImage({ + logo: 'https://example.com/logo.png', + logo_url: 'https://example.com/logo_url.png', + }); + assert.equal(got, 'https://example.com/logo.png'); +}); + +test('getCompanyImage — logo_url is the last URL fallback before generated avatar', () => { + const got = getCompanyImage({ logo_url: 'https://example.com/lu.png' }); + assert.equal(got, 'https://example.com/lu.png'); +}); + +test('getCompanyImage — empty/whitespace strings DO fall through to next fallback', () => { + // Subtle: empty string and " " count as missing per the .trim() check. + // A future refactor that drops the trim would silently cause "" or " " to + // be returned as the image src, breaking the . + const got = getCompanyImage({ + image: ' ', + logo: '', + logo_url: 'https://example.com/lu.png', + }); + assert.equal(got, 'https://example.com/lu.png'); +}); + +test('getCompanyImage — falls through to generateFallbackImage when all URLs missing', () => { + const got = getCompanyImage({ name: 'Acme Corp', id: 5 }); + assert.match(got, /^https:\/\/ui-avatars\.com\/api\//, + `expected UI-Avatars URL; got "${got}"`); + assert.match(got, /name=AC/, `initials must be "AC" for "Acme Corp"`); +}); + +test('getCompanyImage — missing name uses "Unknown" → initials "U"', () => { + const got = getCompanyImage({ id: 1 }); + assert.match(got, /name=U/, `expected name=U from "Unknown"; got "${got}"`); +}); + +// --------------------------------------------------------------------------- +// getInitials +// --------------------------------------------------------------------------- + +test('getInitials — empty/null returns "CO"', () => { + assert.equal(getInitials(null), 'CO'); + assert.equal(getInitials(undefined), 'CO'); + assert.equal(getInitials(''), 'CO'); + assert.equal(getInitials(123), 'CO'); +}); + +test('getInitials — single word: first letter only', () => { + assert.equal(getInitials('Acme'), 'A'); + assert.equal(getInitials('gnosis'), 'G'); +}); + +test('getInitials — multi-word: first letter of first two words', () => { + assert.equal(getInitials('Acme Corp'), 'AC'); + assert.equal(getInitials('Curve Decentralized Exchange'), 'CD', + 'must take only first 2 letters; ignores third word'); +}); + +test('getInitials — collapses extra spaces (filters empty splits)', () => { + assert.equal(getInitials('Acme Corp'), 'AC', + 'multi-space gap should not introduce empty initials'); + assert.equal(getInitials(' Acme '), 'A', + 'leading/trailing space should not count as words'); +}); + +// --------------------------------------------------------------------------- +// generateColorFromId — palette modulo +// --------------------------------------------------------------------------- + +test('generateColorFromId — id=0 returns first color', () => { + assert.equal(generateColorFromId(0), '4F46E5'); +}); + +test('generateColorFromId — wraps around palette at id=10', () => { + // 10 colors; id 10 → idx 0 + assert.equal(generateColorFromId(10), generateColorFromId(0)); + assert.equal(generateColorFromId(11), generateColorFromId(1)); +}); + +test('generateColorFromId — string id parsed to integer', () => { + assert.equal(generateColorFromId('5'), generateColorFromId(5)); + // Non-numeric string → parseInt returns NaN → || 0 → color[0] + assert.equal(generateColorFromId('not-a-number'), generateColorFromId(0)); +}); + +// --------------------------------------------------------------------------- +// generateColorFromString — deterministic hash +// --------------------------------------------------------------------------- + +test('generateColorFromString — same input → same color (deterministic)', () => { + assert.equal(generateColorFromString('Acme'), generateColorFromString('Acme')); +}); + +test('generateColorFromString — different input → typically different color', () => { + assert.notEqual(generateColorFromString('Acme'), generateColorFromString('Globex'), + 'collisions exist but two distinct simple names should not collide'); +}); + +test('generateColorFromString — empty/non-string returns gray-500 default', () => { + assert.equal(generateColorFromString(''), '6B7280'); + assert.equal(generateColorFromString(null), '6B7280'); + assert.equal(generateColorFromString(123), '6B7280'); +}); + +test('generateColorFromString — output is always a 6-char hex (zero-padded)', () => { + for (const s of ['a', 'A', 'Acme', '🦊', 'long-string-of-text']) { + const c = generateColorFromString(s); + assert.equal(c.length, 6, `color for "${s}" not 6 chars: "${c}"`); + assert.match(c, /^[0-9a-f]{6}$/, `color for "${s}" not lowercase hex: "${c}"`); + } +}); + +// --------------------------------------------------------------------------- +// generateFallbackImage — UI Avatars URL structure +// --------------------------------------------------------------------------- + +test('generateFallbackImage — produces a valid UI Avatars URL with initials and bg color', () => { + const url = generateFallbackImage('Acme Corp', 3); + assert.match(url, /^https:\/\/ui-avatars\.com\/api\/\?/); + assert.match(url, /name=AC/); + assert.match(url, new RegExp(`background=${generateColorFromId(3)}`)); + assert.match(url, /size=200/); + assert.match(url, /rounded=true/); +}); + +test('getDefaultFallbackImage — pinned URL', () => { + // Pin the exact URL so a refactor that tweaks it (e.g. switches to + // /assets/default-company-logo.png instead) surfaces immediately. + assert.equal( + getDefaultFallbackImage(), + 'https://ui-avatars.com/api/?name=CO&size=200&background=6B7280&color=fff&bold=true&rounded=true' + ); +}); diff --git a/auto-qa/tests/impact-formula.test.mjs b/auto-qa/tests/impact-formula.test.mjs new file mode 100644 index 0000000..01f98ab --- /dev/null +++ b/auto-qa/tests/impact-formula.test.mjs @@ -0,0 +1,201 @@ +/** + * Impact formula test (auto-qa). + * + * Pins PR #31: "Fix Impact showing 0% by using candle close prices". + * + * Background: the subgraph `tick` field on YES and NO CONDITIONAL pools + * was sometimes stale/identical (both showed -50491 on the AAVE market) + * even when the pools' real prices had diverged. Tick-derived prices + * came out equal → impact = (yes - no)/spot * 100 = 0%, the wrong + * answer. PR #31 swaps tick-derived prices for candle close prices so + * YES and NO actually differ. + * + * The impact *formula* is unchanged by PR #31 — what changed is which + * inputs feed into it. So the test pins the formula AND demonstrates + * the bug condition: when YES and NO prices are equal, impact is 0 + * regardless of formula path; when they diverge (post-PR-#31), the + * formula yields the displayed impact percentage. + * + * Spec mirrors: + * src/components/chart/SubgraphChart.jsx:515-526 (single-point card) + * src/components/chart/TripleChart.jsx:120-131 (chart series) + * + * If those line ranges change, this test may need a synchronized update. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +/** + * Mirror of SubgraphChart.jsx:515-526. Computes the Impact % shown on + * the SubgraphChart card. Two paths: + * 1. Spot price available → (yes - no)/spot * 100 (preferred) + * 2. No spot → (yes - no)/max(yes, no) * 100 (fallback) + * + * @param {number|null} yesPrice + * @param {number|null} noPrice + * @param {number|null} spotPrice + * @param {boolean} showSpot + * @returns {number} impact percentage + */ +function impactPercent(yesPrice, noPrice, spotPrice, showSpot) { + if (yesPrice === null || noPrice === null) return 0; + if (showSpot && spotPrice && spotPrice > 0) { + return ((yesPrice - noPrice) / spotPrice) * 100; + } + const denominator = Math.max(yesPrice, noPrice); + return denominator > 0 ? ((yesPrice - noPrice) / denominator) * 100 : 0; +} + +/** + * Mirror of TripleChart.jsx:120-131 — the per-timestamp impact series + * used by the chart line. Always uses the spot-based formula; skips any + * timestamp where any of the three values is missing or spot <= 0. + */ +function impactSeries(yesData, noData, spotData) { + const map = new Map(); + yesData.forEach(d => { if (!map.has(d.time)) map.set(d.time, {}); map.get(d.time).yes = d.value; }); + noData.forEach(d => { if (!map.has(d.time)) map.set(d.time, {}); map.get(d.time).no = d.value; }); + spotData.forEach(d => { if (!map.has(d.time)) map.set(d.time, {}); map.get(d.time).spot = d.value; }); + + const out = []; + map.forEach((v, time) => { + if (v.yes !== undefined && v.no !== undefined && v.spot !== undefined && v.spot > 0) { + out.push({ time, value: ((v.yes - v.no) / v.spot) * 100 }); + } + }); + out.sort((a, b) => a.time - b.time); + return out; +} + +const EPS = 1e-9; +const close = (a, b) => Math.abs(a - b) < EPS; + +// --------------------------------------------------------------------------- +// PR #31 — bug condition: identical YES & NO prices → 0% impact +// --------------------------------------------------------------------------- + +test('PR #31 — bug repro: equal YES/NO prices yield 0% impact (spot path)', () => { + // Pre-PR #31, both pools' tick-derived prices came back equal (subgraph + // staleness). The formula isn't broken — but the inputs were degenerate. + const yes = 120, no = 120, spot = 100; + assert.equal(impactPercent(yes, no, spot, true), 0, + 'when YES == NO the formula yields exactly 0 — explains the user-visible "Impact 0.00%" bug'); +}); + +test('PR #31 — bug repro: equal YES/NO prices yield 0% impact (fallback path)', () => { + const yes = 134.45, no = 134.45; + assert.equal(impactPercent(yes, no, null, false), 0, + 'fallback path: equal prices also yield 0'); +}); + +// --------------------------------------------------------------------------- +// PR #31 — fixed condition: candle-close prices diverge → meaningful impact +// --------------------------------------------------------------------------- + +test('PR #31 — AAVE example: divergent candle prices give ~20% impact (spot path)', () => { + // From the PR body: "YES: 134.45, NO: 106.68 GHO/AAVE for AAVE market" + // and the description says Impact should show "~20% (not 0.00%)". + // We don't know the exact spot price the PR author used; pick one that + // brackets the documented "~20%" expectation. + const yes = 134.45, no = 106.68; + const spot = 134.45; // an at-the-money spot ≈ YES gives the upper bound + const impact = impactPercent(yes, no, spot, true); + // (134.45 - 106.68)/134.45 * 100 ≈ 20.65% + assert.ok(impact > 19 && impact < 22, + `expected ~20% impact for AAVE-style spread; got ${impact.toFixed(2)}%`); +}); + +test('PR #31 — fallback path: divergent prices yield non-zero impact', () => { + // No spot available (showSpot=false or spot=0): falls back to + // (yes - no)/max(yes, no) * 100. With yes > no this is the same + // upper-bound formula. + const impact = impactPercent(134.45, 106.68, null, false); + assert.ok(close(impact, ((134.45 - 106.68) / 134.45) * 100), + `fallback should equal (yes-no)/max(yes,no)*100; got ${impact}`); +}); + +// --------------------------------------------------------------------------- +// Sign / direction invariants +// --------------------------------------------------------------------------- + +test('formula — YES > NO yields positive impact', () => { + const i = impactPercent(110, 100, 105, true); + assert.ok(i > 0, `YES > NO should be positive impact; got ${i}`); +}); + +test('formula — NO > YES yields negative impact', () => { + const i = impactPercent(100, 110, 105, true); + assert.ok(i < 0, `NO > YES should be negative impact; got ${i}`); +}); + +test('formula — symmetric: swapping YES and NO flips the sign', () => { + const a = impactPercent(120, 100, 110, true); + const b = impactPercent(100, 120, 110, true); + assert.ok(close(a, -b), `expected ${a} === -${b}`); +}); + +// --------------------------------------------------------------------------- +// Defensive guards +// --------------------------------------------------------------------------- + +test('formula — null YES or NO yields 0% (guard)', () => { + assert.equal(impactPercent(null, 100, 100, true), 0); + assert.equal(impactPercent(100, null, 100, true), 0); +}); + +test('formula — spot=0 falls back to denominator path (no div-by-zero)', () => { + // spotPrice > 0 guard sends us into the fallback branch. + const i = impactPercent(120, 100, 0, true); + // Fallback: (120-100)/max(120,100)*100 = 16.67 + assert.ok(close(i, ((120 - 100) / 120) * 100), `got ${i}`); +}); + +test('formula — spot=0 with YES==NO==0 fallback yields 0 (no div-by-zero)', () => { + const i = impactPercent(0, 0, 0, true); + assert.equal(i, 0, 'denominator=0 must short-circuit to 0'); +}); + +// --------------------------------------------------------------------------- +// Series-builder invariants (TripleChart.jsx) +// --------------------------------------------------------------------------- + +test('series — drops timestamps missing any of yes/no/spot', () => { + const yes = [{ time: 1, value: 110 }, { time: 2, value: 115 }, { time: 3, value: 120 }]; + const no = [{ time: 1, value: 100 }, { time: 2, value: 102 }]; // missing t=3 + const spot = [{ time: 1, value: 105 }, { time: 2, value: 108 }, { time: 3, value: 112 }]; + const s = impactSeries(yes, no, spot); + assert.equal(s.length, 2, `expected 2 series points (t=1,2 only); got ${s.length}`); + assert.deepEqual(s.map(p => p.time), [1, 2]); +}); + +test('series — drops timestamps where spot <= 0 (guards div-by-zero)', () => { + const yes = [{ time: 1, value: 110 }, { time: 2, value: 115 }]; + const no = [{ time: 1, value: 100 }, { time: 2, value: 102 }]; + const spot = [{ time: 1, value: 0 }, { time: 2, value: 108 }]; // t=1 has spot=0 + const s = impactSeries(yes, no, spot); + assert.equal(s.length, 1, `t=1 must be skipped (spot=0); got ${s.length}`); + assert.equal(s[0].time, 2); +}); + +test('series — output sorted by time ascending', () => { + const yes = [{ time: 3, value: 120 }, { time: 1, value: 110 }, { time: 2, value: 115 }]; + const no = [{ time: 3, value: 102 }, { time: 1, value: 100 }, { time: 2, value: 101 }]; + const spot = [{ time: 3, value: 112 }, { time: 1, value: 105 }, { time: 2, value: 108 }]; + const s = impactSeries(yes, no, spot); + const times = s.map(p => p.time); + assert.deepEqual(times, [...times].sort((a, b) => a - b), 'series must be sorted by time'); +}); + +test('series — degenerate yes==no case yields 0 at every point (PR #31 bug)', () => { + // Pre-PR #31: every series point had yes == no (tick collapse) → impact + // line was a flat 0%. This is the chart-level companion to the card-level + // bug repro test above. + const yes = [{ time: 1, value: 120 }, { time: 2, value: 121 }]; + const no = [{ time: 1, value: 120 }, { time: 2, value: 121 }]; + const spot = [{ time: 1, value: 100 }, { time: 2, value: 100 }]; + const s = impactSeries(yes, no, spot); + assert.equal(s.length, 2); + assert.ok(s.every(p => p.value === 0), + `equal yes/no must produce a flat 0 series — pre-PR-#31 chart symptom`); +}); diff --git a/auto-qa/tests/is-safe-wallet.test.mjs b/auto-qa/tests/is-safe-wallet.test.mjs new file mode 100644 index 0000000..b8a9478 --- /dev/null +++ b/auto-qa/tests/is-safe-wallet.test.mjs @@ -0,0 +1,220 @@ +/** + * isSafeWallet detection spec mirror (auto-qa). + * + * Pins src/utils/ethersAdapters.js:isSafeWallet — used by + * AddLiquidityModal and ConfirmSwapModal to gate Safe-specific tx + * flows. The function has THREE independent detection paths: + * + * 1. Wagmi connector: name OR id contains "safe" OR name contains "gnosis" + * 2. window.ethereum: isSafe===true OR isSafeApp===true + * 3. document.referrer: contains "safe.global" + * + * If ANY path matches → returns true. The detection is intentionally + * permissive because false positives are harmless (Safe flow gracefully + * degrades) but false negatives skip the Safe-specific waitForSafeTxReceipt + * flow and the user sees a tx hash that never confirms. + * + * Spec mirrors the function. Globals (window, document) are stubbed + * via globalThis assignments per test (cleanup after each). + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// --- spec mirror --- + +function isSafeWallet(walletClient) { + const connectorName = walletClient?.connector?.name?.toLowerCase() || ''; + const connectorId = walletClient?.connector?.id?.toLowerCase() || ''; + + if (connectorName.includes('safe') || connectorId.includes('safe') || connectorName.includes('gnosis')) { + return true; + } + if (typeof window !== 'undefined' && window.ethereum) { + if (window.ethereum.isSafe === true) return true; + if (window.ethereum.isSafeApp === true) return true; + } + if (typeof document !== 'undefined' && document.referrer?.includes('safe.global')) { + return true; + } + return false; +} + +// Test isolation helpers — set/reset globalThis.window and globalThis.document +function withGlobals({ ethereum, referrer }, fn) { + const origWindow = globalThis.window; + const origDocument = globalThis.document; + try { + if (ethereum !== undefined) globalThis.window = { ethereum }; + if (referrer !== undefined) globalThis.document = { referrer }; + return fn(); + } finally { + globalThis.window = origWindow; + globalThis.document = origDocument; + } +} + +// --------------------------------------------------------------------------- +// Path 1: Wagmi connector +// --------------------------------------------------------------------------- + +test('isSafeWallet — connector.name contains "safe" → true', () => { + assert.equal(isSafeWallet({ connector: { name: 'Safe Wallet' } }), true); + assert.equal(isSafeWallet({ connector: { name: 'safe' } }), true); +}); + +test('isSafeWallet — connector.name case-insensitive (SAFE, sAfE)', () => { + // .toLowerCase() applied before .includes() — regression that + // drops the lowercase would miss connectors named e.g. "SAFE Wallet". + assert.equal(isSafeWallet({ connector: { name: 'SAFE Wallet' } }), true); + assert.equal(isSafeWallet({ connector: { name: 'sAfE' } }), true); +}); + +test('isSafeWallet — connector.id contains "safe" → true', () => { + assert.equal(isSafeWallet({ connector: { id: 'safe-connector' } }), true); + assert.equal(isSafeWallet({ connector: { id: 'SAFE_APPS' } }), true); +}); + +test('isSafeWallet — connector.name contains "gnosis" → true (Safe ↔ Gnosis Safe historic)', () => { + // Pinned: "gnosis" matches in name only (not id). This is because + // historically Safe was called "Gnosis Safe" and some connectors + // still use the old name. + assert.equal(isSafeWallet({ connector: { name: 'Gnosis Safe Wallet' } }), true); + assert.equal(isSafeWallet({ connector: { name: 'gnosis' } }), true); +}); + +test('isSafeWallet — connector.id contains "gnosis" alone does NOT trigger (name-only check)', () => { + // Pinned current behavior: the "gnosis" check is only on name, not id. + // This is asymmetric with the "safe" check. Documenting via test. + const r = isSafeWallet({ connector: { id: 'gnosis-chain-rpc', name: 'Some Wallet' } }); + assert.equal(r, false, + `current behavior: "gnosis" check only on name, not id. ` + + `If we ever add it to id check too, this test pin guides the deliberate update.`); +}); + +// --------------------------------------------------------------------------- +// Defensive guards +// --------------------------------------------------------------------------- + +test('isSafeWallet — null/undefined walletClient does NOT throw', () => { + // Optional chaining defends both .connector?.name and .connector?.id. + assert.doesNotThrow(() => isSafeWallet(null)); + assert.doesNotThrow(() => isSafeWallet(undefined)); +}); + +test('isSafeWallet — walletClient with no connector → falls through to global checks', () => { + // No throw, falls through to window/document checks (which return false in default env). + const r = withGlobals({}, () => isSafeWallet({})); + assert.equal(r, false); +}); + +test('isSafeWallet — walletClient with empty connector → falls through to false', () => { + const r = withGlobals({}, () => isSafeWallet({ connector: {} })); + assert.equal(r, false); +}); + +// --------------------------------------------------------------------------- +// Path 2: window.ethereum.isSafe / isSafeApp +// --------------------------------------------------------------------------- + +test('isSafeWallet — window.ethereum.isSafe === true → true', () => { + const r = withGlobals( + { ethereum: { isSafe: true } }, + () => isSafeWallet({}) + ); + assert.equal(r, true); +}); + +test('isSafeWallet — window.ethereum.isSafeApp === true → true', () => { + const r = withGlobals( + { ethereum: { isSafeApp: true } }, + () => isSafeWallet({}) + ); + assert.equal(r, true); +}); + +test('isSafeWallet — window.ethereum.isSafe must be STRICTLY === true (truthy is not enough)', () => { + // Pinned: the check is `=== true`, not just truthy. A "safe" string + // or 1 wouldn't trigger. Defensive against frame providers that + // return non-boolean values. + const r1 = withGlobals( + { ethereum: { isSafe: 'true' } }, + () => isSafeWallet({}) + ); + assert.equal(r1, false, `string "true" must NOT match strict === true`); + const r2 = withGlobals( + { ethereum: { isSafe: 1 } }, + () => isSafeWallet({}) + ); + assert.equal(r2, false, `number 1 must NOT match strict === true`); +}); + +test('isSafeWallet — window.ethereum without isSafe/isSafeApp → false (in absence of other paths)', () => { + const r = withGlobals( + { ethereum: { isMetaMask: true } }, + () => isSafeWallet({}) + ); + assert.equal(r, false); +}); + +// --------------------------------------------------------------------------- +// Path 3: document.referrer +// --------------------------------------------------------------------------- + +test('isSafeWallet — document.referrer contains "safe.global" → true', () => { + const r = withGlobals( + { referrer: 'https://app.safe.global/foo' }, + () => isSafeWallet({}) + ); + assert.equal(r, true); +}); + +test('isSafeWallet — document.referrer is empty → false (no path 3 match)', () => { + const r = withGlobals( + { referrer: '' }, + () => isSafeWallet({}) + ); + assert.equal(r, false); +}); + +test('isSafeWallet — document.referrer is unrelated → false', () => { + const r = withGlobals( + { referrer: 'https://twitter.com/somewhere' }, + () => isSafeWallet({}) + ); + assert.equal(r, false); +}); + +test('isSafeWallet — document undefined does NOT throw (typeof guard)', () => { + // Path 3 only fires if document exists. SSR / non-browser env should + // not crash on the function call. + const origDoc = globalThis.document; + delete globalThis.document; + try { + assert.doesNotThrow(() => isSafeWallet({})); + assert.equal(isSafeWallet({}), false); + } finally { + globalThis.document = origDoc; + } +}); + +// --------------------------------------------------------------------------- +// Combined: any path matches → true (OR semantics) +// --------------------------------------------------------------------------- + +test('isSafeWallet — connector path wins even if other paths would also match', () => { + // Doesn't matter which short-circuits — result is still true. + const r = withGlobals( + { ethereum: { isSafe: true }, referrer: 'https://safe.global/' }, + () => isSafeWallet({ connector: { name: 'Safe' } }) + ); + assert.equal(r, true); +}); + +test('isSafeWallet — all three paths false → returns false', () => { + const r = withGlobals( + { ethereum: { isMetaMask: true }, referrer: 'https://other.com/' }, + () => isSafeWallet({ connector: { name: 'MetaMask', id: 'metamask' } }) + ); + assert.equal(r, false); +}); diff --git a/auto-qa/tests/json-config-validity.test.mjs b/auto-qa/tests/json-config-validity.test.mjs new file mode 100644 index 0000000..275049b --- /dev/null +++ b/auto-qa/tests/json-config-validity.test.mjs @@ -0,0 +1,146 @@ +/** + * JSON config validity test (auto-qa). + * + * Walks every JSON file in src/, public/, and the repo root (excluding + * package-lock.json + node_modules) and asserts: + * + * 1. The file parses cleanly as JSON (catches trailing commas, + * missing braces, BOM markers, accidental Markdown fences) + * 2. For known structural files (manifest.json, package.json, + * mapped-seo.json), specific shape invariants hold + * + * A bad JSON file in src/ or public/ silently breaks SSR or static + * asset serving — Next.js may throw a vague "Unexpected token" with + * no file path. This test surfaces it with a clear file:line message. + * + * The mapped-seo.json key-shape check pins that every proposal-id + * key is a valid 0x-prefixed 42-char address — guards against a + * search/replace that mangles addresses. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url)); + +function walk(dir, results = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === '.next' || name.startsWith('.')) continue; + const full = join(dir, name); + const st = statSync(full); + if (st.isDirectory()) walk(full, results); + else if (name.endsWith('.json') && name !== 'package-lock.json') results.push(full); + } + return results; +} + +// Top-level files only (no walk into directories) — repo root has many +// scratch JSON files that aren't shipped. +function topLevelJson(dir) { + return readdirSync(dir) + .filter(n => n.endsWith('.json') && n !== 'package-lock.json') + .map(n => join(dir, n)); +} + +const SRC_FILES = walk(join(REPO_ROOT, 'src')); +const PUBLIC_FILES = walk(join(REPO_ROOT, 'public')); +const ROOT_FILES = topLevelJson(REPO_ROOT); + +// --------------------------------------------------------------------------- +// Universal: every JSON file must parse +// --------------------------------------------------------------------------- + +test('JSON validity — extractor finds non-trivial number of files', () => { + const total = SRC_FILES.length + PUBLIC_FILES.length + ROOT_FILES.length; + assert.ok(total >= 5, + `extractor found only ${total} JSON files — walker / filter likely broken`); +}); + +const allFiles = [...SRC_FILES, ...PUBLIC_FILES, ...ROOT_FILES]; +for (const file of allFiles) { + const rel = relative(REPO_ROOT, file); + test(`JSON validity — ${rel} parses`, () => { + const raw = readFileSync(file, 'utf8'); + // Detect BOM (sometimes added by Windows editors → JSON.parse throws). + assert.ok(raw.charCodeAt(0) !== 0xFEFF, + `${rel} starts with a UTF-8 BOM marker — strip it`); + // Try to parse; surface a rich error if it fails. + try { + JSON.parse(raw); + } catch (e) { + assert.fail(`${rel} failed to parse as JSON: ${e.message}`); + } + }); +} + +// --------------------------------------------------------------------------- +// public/manifest.json — Web App Manifest minimal shape +// --------------------------------------------------------------------------- + +test('manifest.json — has name + description fields', () => { + const m = JSON.parse(readFileSync(join(REPO_ROOT, 'public/manifest.json'), 'utf8')); + assert.ok(typeof m.name === 'string' && m.name.length > 0, + `manifest.json must have a name field`); + assert.ok(typeof m.description === 'string' && m.description.length > 0, + `manifest.json must have a description field`); +}); + +test('safe-app-manifest.json — has name + iconPath fields', () => { + const m = JSON.parse(readFileSync(join(REPO_ROOT, 'public/safe-app-manifest.json'), 'utf8')); + // Safe (Gnosis Safe) app manifest spec: name, description, iconPath. + for (const k of ['name', 'description', 'iconPath']) { + assert.ok(typeof m[k] === 'string' && m[k].length > 0, + `safe-app-manifest.json must have a non-empty "${k}" field`); + } +}); + +// --------------------------------------------------------------------------- +// src/config/mapped-seo.json — every key is a 0x-address +// --------------------------------------------------------------------------- + +test('mapped-seo.json — every key is a valid 0x + 40 hex chars address', () => { + const m = JSON.parse(readFileSync(join(REPO_ROOT, 'src/config/mapped-seo.json'), 'utf8')); + const ADDR = /^0x[a-fA-F0-9]{40}$/; + const bad = Object.keys(m).filter(k => !ADDR.test(k)); + assert.equal(bad.length, 0, + `${bad.length} mapped-seo.json key(s) failed address shape check:\n${bad.map(k => ' ' + k).join('\n')}`); +}); + +test('mapped-seo.json — every value is a path-relative string', () => { + const m = JSON.parse(readFileSync(join(REPO_ROOT, 'src/config/mapped-seo.json'), 'utf8')); + const bad = []; + for (const [k, v] of Object.entries(m)) { + if (typeof v !== 'string') { bad.push(`${k}: not string (${typeof v})`); continue; } + if (!v.startsWith('/')) { bad.push(`${k}: not path-relative ("${v}")`); } + } + assert.equal(bad.length, 0, + `${bad.length} mapped-seo.json value(s) failed shape check:\n${bad.slice(0, 10).map(b => ' ' + b).join('\n')}`); +}); + +test('mapped-seo.json — non-trivial number of entries', () => { + const m = JSON.parse(readFileSync(join(REPO_ROOT, 'src/config/mapped-seo.json'), 'utf8')); + const n = Object.keys(m).length; + assert.ok(n >= 10, `mapped-seo.json has ${n} entries — likely truncated`); +}); + +// --------------------------------------------------------------------------- +// Root package.json — required dev/runtime dep names + scripts +// --------------------------------------------------------------------------- + +test('package.json — has required scripts (dev, build, lint, auto-qa:test)', () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')); + for (const s of ['dev', 'build', 'lint', 'auto-qa:test']) { + assert.ok(pkg.scripts?.[s], `package.json missing script "${s}"`); + } +}); + +test('package.json — declares ethers and next as dependencies', () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')); + const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; + for (const dep of ['ethers', 'next']) { + assert.ok(all[dep], `package.json missing dep "${dep}"`); + } +}); diff --git a/auto-qa/tests/liquidity-math.test.mjs b/auto-qa/tests/liquidity-math.test.mjs new file mode 100644 index 0000000..5888867 --- /dev/null +++ b/auto-qa/tests/liquidity-math.test.mjs @@ -0,0 +1,106 @@ +/** + * Liquidity math unit-bound test (auto-qa). + * + * Pins the math fix from PR #51 — converting Algebra V3's raw `liquidity` + * field (1e18-scaled) to a currency-denominated TVL via: + * TVL_currency = (2 × L × sqrtPrice) / 1e18 (when currency is token1) + * TVL_currency = (2 × L / sqrtPrice) / 1e18 (when currency is token0) + * where sqrtPrice = 1.0001^(tick/2). + * + * Pre-PR-#51, the widget displayed the raw L value as "sDAI" — values + * like "4.9e21 sDAI" — nonsense. + * + * This is a SPEC test: the math here mirrors the production code at + * `src/hooks/usePoolData.js:141-153`. If those lines change, this test + * may need a synchronized update — that's the contract. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +/** + * Mirrors the production calculation at src/hooks/usePoolData.js:141-153. + * @param {string|number} rawL Algebra V3 `liquidity` field (1e18-scaled BigInt). + * @param {number} tick Current pool tick. + * @param {boolean} currencyIsToken0 + * @returns {number} TVL in currency token units (NOT wei), or 0 if tick missing. + */ +function tvlFromTick(rawL, tick, currencyIsToken0) { + let adjustedLiquidity = parseFloat(rawL || 0); + if (tick === undefined || tick === null) return adjustedLiquidity / 1e18; + const sqrtPrice = Math.pow(1.0001, tick / 2); + if (!(sqrtPrice > 0)) return 0; + const liquidityScaled = currencyIsToken0 + ? adjustedLiquidity / sqrtPrice + : adjustedLiquidity * sqrtPrice; + return (liquidityScaled * 2) / 1e18; +} + +// ──────────────────────────────────────────────────────────────────────── +// Fixture: GIP-150 v2 CONDITIONAL pool data captured from live API. +// ──────────────────────────────────────────────────────────────────────── +const YES_POOL = { + L: '4900580174367112592095', + tick: 47116, + currencyIsToken0: false, // token0=YES_GNO, token1=YES_sDAI +}; +const NO_POOL = { + L: '4900580174367112321476', + tick: -46766, + currencyIsToken0: true, // token0=NO_sDAI, token1=NO_GNO +}; + +test('PR #51 — YES pool TVL is in plausible currency-units range', () => { + const tvl = tvlFromTick(YES_POOL.L, YES_POOL.tick, YES_POOL.currencyIsToken0); + assert.ok(tvl > 1, + `YES pool TVL must be > 1 sDAI; got ${tvl}`); + assert.ok(tvl < 1e9, + `YES pool TVL must be < 1e9 sDAI (raw 1e18 leak guard); got ${tvl}`); + // Sanity: GIP-150's CONDITIONAL pools sit around 100K sDAI TVL. + assert.ok(tvl > 1e4 && tvl < 1e7, + `YES pool TVL should be in 10K..10M range for GIP-150; got ${tvl}`); +}); + +test('PR #51 — NO pool TVL is in plausible currency-units range', () => { + const tvl = tvlFromTick(NO_POOL.L, NO_POOL.tick, NO_POOL.currencyIsToken0); + assert.ok(tvl > 1, `NO pool TVL must be > 1 sDAI; got ${tvl}`); + assert.ok(tvl < 1e9, `NO pool TVL must be < 1e9 sDAI; got ${tvl}`); + assert.ok(tvl > 1e4 && tvl < 1e7, + `NO pool TVL should be in 10K..10M range for GIP-150; got ${tvl}`); +}); + +test('PR #51 — YES and NO TVL are within 50% of each other (futarchy invariant)', () => { + // For a healthy futarchy market the YES and NO conditional pools + // start with the same liquidity. They drift but should stay + // within an order of magnitude. If one is 100× the other, the + // tick-based formula is broken on one side. + const yesTvl = tvlFromTick(YES_POOL.L, YES_POOL.tick, YES_POOL.currencyIsToken0); + const noTvl = tvlFromTick(NO_POOL.L, NO_POOL.tick, NO_POOL.currencyIsToken0); + const ratio = Math.max(yesTvl, noTvl) / Math.min(yesTvl, noTvl); + assert.ok(ratio < 2, + `YES and NO TVL should be within 2x of each other; got ratio ${ratio.toFixed(2)} (YES=${yesTvl.toFixed(0)} NO=${noTvl.toFixed(0)})`); +}); + +test('PR #51 — fallback /1e18 when tick missing yields plausible value', () => { + const tvl = tvlFromTick(YES_POOL.L, null, false); + // Without tick, formula falls back to L / 1e18 ≈ 4900 (raw L is + // a 1e18-scaled BigInt). This is a degraded fallback but still + // bounded — must not leak the raw 1e21 value. + assert.ok(tvl > 1 && tvl < 1e9, + `fallback TVL out of bounds: ${tvl}`); +}); + +test('PR #51 — degenerate tick (sqrtPrice=0) returns 0, not Infinity/NaN', () => { + // Algebra V3 ticks are bounded in [-887272, 887272]. At extreme + // negative ticks Math.pow(1.0001, tick/2) underflows to 0 — the + // production guard returns 0 rather than dividing by zero. + const tvl = tvlFromTick('1000000000000000000', -1_000_000, true); + assert.ok(tvl === 0 || (Number.isFinite(tvl) && tvl >= 0), + `extreme negative tick should not yield Infinity/NaN; got ${tvl}`); +}); + +test('PR #51 — null/zero L returns 0, not NaN', () => { + assert.equal(tvlFromTick(null, 0, false), 0); + assert.equal(tvlFromTick(undefined, 0, false), 0); + assert.equal(tvlFromTick('0', 0, false), 0); +}); diff --git a/auto-qa/tests/pagination-first-cap.test.mjs b/auto-qa/tests/pagination-first-cap.test.mjs new file mode 100644 index 0000000..e5475c7 --- /dev/null +++ b/auto-qa/tests/pagination-first-cap.test.mjs @@ -0,0 +1,137 @@ +/** + * Pagination `first:` cap lint (auto-qa). + * + * Pins the family of bugs that landed as PR #44 — the Companies admin + * UI silently dropped GIP-150 because `getLinkableProposals` queried + * with `first: 50` against a registry that already had 229 proposals. + * + * General class of bug: a hardcoded `first: ` on an entity + * type whose population can grow past N silently truncates results. + * + * What this test does: + * Walks every shipped GraphQL query (via the same extractor used by + * the schema-compat probe) and flags entity-listing queries that use + * `first: ` for entities likely to grow past 100. Today's risk + * list: organizations, proposals, proposalentities, pools. + * + * Per /loop directive: failures here are documented as a baseline of + * "known small-first usages we accept" — adding a NEW one trips the + * test, so this is a ratchet against silent-truncation regressions. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EXTRACTOR = resolve(__dirname, '../tools/extract-graphql.mjs'); + +// Listing-style queries we audit. Format: `(... first: )` with N < threshold +const RISKY_ENTITIES = ['proposals', 'proposalentities', 'organizations', 'pools']; + +// Threshold: anything under this is suspicious for entities that grow. +// 100 captures the post-PR-#44 change (50 → 1000) — anything below is +// either intentionally narrow or a future PR-#44-style bug. +const FIRST_THRESHOLD = 100; + +function loadQueries() { + const out = execFileSync('node', [EXTRACTOR], { + encoding: 'utf8', + cwd: resolve(__dirname, '../..'), + }); + return JSON.parse(out); +} + +function findRiskyFirstUsages(queries) { + const findings = []; + for (const q of queries) { + // Match `()` where args contain `first: `. + // Handle `entity(first: N, …)`, `entity(…, first: N)`, etc. + for (const entity of RISKY_ENTITIES) { + const re = new RegExp( + `\\b${entity}\\s*\\([^)]*first\\s*:\\s*(\\d+)`, + 'gi' + ); + let m; + while ((m = re.exec(q.query)) !== null) { + const n = parseInt(m[1], 10); + if (n < FIRST_THRESHOLD) { + findings.push({ + file: q.file, + line: q.line, + entity, + first: n, + snippet: m[0].slice(0, 80), + }); + } + } + } + } + return findings; +} + +// Baseline: known small-first usages we accept today. Exhaustive list as +// of this iteration. If a new entry appears, it's flagged for review. +const ACCEPTED_SMALL_FIRST = [ + // useSearchProposals: search box deliberately shows only top-20. + { file: 'src/hooks/useSearchProposals.js', entity: 'proposals', first: 20 }, + // usePoolData: single-pool lookup by ID (`pools(where: { id: "0xabc" }, first: 1)`) + // is intentionally narrow — we want exactly one pool, not many. + { file: 'src/hooks/usePoolData.js', entity: 'pools', first: 1 }, +]; + +function isAccepted(finding) { + return ACCEPTED_SMALL_FIRST.some( + a => a.file === finding.file && a.entity === finding.entity && a.first === finding.first + ); +} + +test('PR #44 — no NEW small-first usage on listing entities', () => { + const queries = loadQueries(); + const findings = findRiskyFirstUsages(queries); + const surprises = findings.filter(f => !isAccepted(f)); + + if (surprises.length > 0) { + const lines = surprises.map(s => + ` ${s.file}:${s.line} ${s.entity}(... first: ${s.first} ...) — ${s.snippet}` + ).join('\n'); + assert.fail( + `Found ${surprises.length} new small-first usage(s) on growing entities:\n${lines}\n` + + `Either bump the limit (PR #44-style fix) or, if intentionally narrow, ` + + `add to ACCEPTED_SMALL_FIRST in this test file.` + ); + } +}); + +test('PR #44 — accepted small-first list still matches reality', () => { + // Inverse direction: if an accepted entry was removed (e.g. the + // useSearchProposals query was bumped/reworked), we want to know + // so the accepted list doesn't grow stale. + const queries = loadQueries(); + const findings = findRiskyFirstUsages(queries); + + for (const accepted of ACCEPTED_SMALL_FIRST) { + const stillThere = findings.some(f => + f.file === accepted.file && + f.entity === accepted.entity && + f.first === accepted.first + ); + assert.ok(stillThere, + `Accepted small-first usage no longer found in source: ${JSON.stringify(accepted)}. ` + + `Remove it from ACCEPTED_SMALL_FIRST in this test file.`); + } +}); + +test('PR #44 — diagnostic: report all listing-entity first: values', (t) => { + // Diagnostic-only — emits a count by file:entity:first triple so we + // can see at a glance what pagination defaults the codebase uses. + const queries = loadQueries(); + const findings = findRiskyFirstUsages(queries); + t.diagnostic(`small-first usages found: ${findings.length} (threshold: <${FIRST_THRESHOLD})`); + for (const f of findings) { + t.diagnostic(` ${f.file}:${f.line} ${f.entity} first=${f.first}`); + } + assert.ok(true); +}); diff --git a/auto-qa/tests/precision-formatter.test.mjs b/auto-qa/tests/precision-formatter.test.mjs new file mode 100644 index 0000000..86a9ead --- /dev/null +++ b/auto-qa/tests/precision-formatter.test.mjs @@ -0,0 +1,221 @@ +/** + * precisionFormatter utility tests (auto-qa). + * + * Pins src/utils/precisionFormatter.js — used in 8 production files + * including SubgraphChart, MarketBalancePanel, PositionsTable, + * ConfirmSwapModal, and ChartParameters. The "smart precision" logic + * (increase precision until value doesn't round to 0) and the + * type-specific trailing-zero handling are subtle enough that subtle + * regressions would slip past code review and only show up as wrong + * numbers on the trading screen. + * + * Spec mirrors src/utils/precisionFormatter.js with the production + * default PRECISION_CONFIG inlined. The mirror omits the production + * console.log spam. + * + * Notable quirk pinned: formatPercentage MULTIPLIES the input by 100, + * so formatPercentage(0.5) → "50%" (not "0.5%"). This is the opposite + * convention from formatNumber.formatSnapshotPercentage which assumes + * the input is already in percent form. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// Production default config — copy of PRECISION_CONFIG.display in +// src/components/futarchyFi/marketPage/constants/contracts.js:358. +const DEFAULT_CONFIG = { + display: { + main: 1, + default: 2, + price: 2, + swapPrice: 1, + amount: 6, + balance: 4, + percentage: 1, + smallNumbers: 8, + }, +}; + +function formatWith(value, type = 'default', config = null) { + const precisionConfig = config || DEFAULT_CONFIG; + if (value === null || value === undefined || value === '' || isNaN(value)) return 'N/A'; + const num = typeof value === 'string' ? parseFloat(value) : value; + if (!isFinite(num)) return 'N/A'; + if (num === 0) return '0'; + + let precision = precisionConfig?.display?.[type] ?? precisionConfig?.display?.default ?? 2; + + // Very-small handling + if (Math.abs(num) < 0.0001 && Math.abs(num) > 0) { + const smallPrecision = precisionConfig?.display?.smallNumbers ?? 20; + return num.toFixed(smallPrecision).replace(/\.?0+$/, ''); + } + + let formatted = num.toFixed(precision); + const originalPrecision = precision; + const maxPrecision = precisionConfig?.display?.smallNumbers ?? 20; + + while (parseFloat(formatted) === 0 && precision < maxPrecision && num !== 0) { + precision++; + formatted = num.toFixed(precision); + } + + if (parseFloat(formatted) === 0 && num !== 0) { + return num.toFixed(maxPrecision).replace(/\.?0+$/, ''); + } + if (precision > originalPrecision) { + return formatted.replace(/\.?0+$/, ''); + } + if (type === 'balance') { + return formatted.replace(/\.?0+$/, ''); + } + return formatted; +} + +function formatPercentage(value, config = null) { + if (value === null || value === undefined || value === '' || isNaN(value)) return 'N/A'; + const num = typeof value === 'string' ? parseFloat(value) : value; + return `${formatWith(num * 100, 'percentage', config)}%`; +} + +function getPrecision(type = 'default', config = null) { + const precisionConfig = config || DEFAULT_CONFIG; + return precisionConfig?.display?.[type] ?? precisionConfig?.display?.default ?? 2; +} + +// --------------------------------------------------------------------------- +// formatWith — invalid input handling +// --------------------------------------------------------------------------- + +test('formatWith — invalid inputs return "N/A"', () => { + for (const v of [null, undefined, '', NaN, 'not a number']) { + assert.equal(formatWith(v, 'price'), 'N/A', + `${JSON.stringify(v)} should yield "N/A"`); + } +}); + +test('formatWith — Infinity returns "N/A"', () => { + assert.equal(formatWith(Infinity, 'price'), 'N/A'); + assert.equal(formatWith(-Infinity, 'price'), 'N/A'); +}); + +test('formatWith — exact zero returns "0" (no decimals, no precision)', () => { + assert.equal(formatWith(0, 'price'), '0'); + assert.equal(formatWith('0', 'price'), '0'); + assert.equal(formatWith(0.0, 'balance'), '0'); +}); + +// --------------------------------------------------------------------------- +// formatWith — type-specific precision lookup +// --------------------------------------------------------------------------- + +test('formatWith — price type uses 2-decimal precision', () => { + assert.equal(formatWith(3.23456, 'price'), '3.23'); +}); + +test('formatWith — amount type uses 6-decimal precision', () => { + assert.equal(formatWith(1234.5, 'amount'), '1234.500000'); +}); + +test('formatWith — balance type strips trailing zeros (display contract)', () => { + // From the function comment: "but keep at least original precision for + // display consistency when non-zero". Trailing zeros are stripped. + assert.equal(formatWith(1.0, 'balance'), '1'); + assert.equal(formatWith(1.2300, 'balance'), '1.23'); +}); + +test('formatWith — non-balance types KEEP trailing zeros', () => { + // Pinned: keep trailing zeros for stable column-aligned display. + assert.equal(formatWith(1.5, 'amount'), '1.500000'); + assert.equal(formatWith(1.5, 'price'), '1.50'); +}); + +test('formatWith — unknown type falls back to default (2 decimals)', () => { + assert.equal(formatWith(3.14159, 'totally-unknown-type'), '3.14'); +}); + +// --------------------------------------------------------------------------- +// formatWith — small-number branching (< 0.0001) +// --------------------------------------------------------------------------- + +test('formatWith — values < 0.0001 use smallNumbers precision (8)', () => { + // toFixed(8) on 0.00001234 = "0.00001234" (no trailing zeros to strip). + assert.equal(formatWith(0.00001234, 'price'), '0.00001234'); +}); + +test('formatWith — small-number path strips trailing zeros', () => { + assert.equal(formatWith(0.00001, 'price'), '0.00001'); +}); + +test('formatWith — negative small numbers use the same path', () => { + assert.equal(formatWith(-0.00001234, 'price'), '-0.00001234'); +}); + +// --------------------------------------------------------------------------- +// formatWith — smart-precision: never display non-zero value as "0" +// --------------------------------------------------------------------------- + +test('formatWith — value rounding to 0 at default precision auto-bumps precision', () => { + // 0.001 with type 'price' (precision=2) would round to "0.00" — the + // smart-precision loop bumps until the displayed value is non-zero. + const out = formatWith(0.001, 'price'); + assert.notEqual(parseFloat(out), 0, + `0.001 must NOT display as zero; got "${out}"`); +}); + +test('formatWith — smart-precision strips trailing zeros after bump', () => { + // After the precision bump, the strip-zeros branch fires. + assert.equal(formatWith(0.001, 'price'), '0.001'); +}); + +// --------------------------------------------------------------------------- +// formatWith — string vs number input parity +// --------------------------------------------------------------------------- + +test('formatWith — accepts string and number forms equivalently', () => { + assert.equal(formatWith(1.5, 'price'), formatWith('1.5', 'price')); + assert.equal(formatWith(0.001, 'amount'), formatWith('0.001', 'amount')); +}); + +// --------------------------------------------------------------------------- +// formatPercentage — input is a *fraction*, not a percent +// --------------------------------------------------------------------------- + +test('formatPercentage — input is multiplied by 100', () => { + // The convention pin: 0.5 → "50.0%", NOT "0.5%". This is opposite + // to formatSnapshotPercentage. If a future refactor "fixes" this + // by removing the *100, every percentage in the app silently + // shrinks by 100×. + assert.equal(formatPercentage(0.5), '50.0%'); + assert.equal(formatPercentage(0.1234), '12.3%'); + assert.equal(formatPercentage(1), '100.0%'); +}); + +test('formatPercentage — invalid → "N/A" (not "N/A%")', () => { + // Invalid input MUST short-circuit before adding the % suffix — + // otherwise users see the literal "N/A%" which looks like a value. + assert.equal(formatPercentage(null), 'N/A'); + assert.equal(formatPercentage(undefined), 'N/A'); + assert.equal(formatPercentage('not-a-num'), 'N/A'); +}); + +// --------------------------------------------------------------------------- +// getPrecision — config lookup +// --------------------------------------------------------------------------- + +test('getPrecision — returns the per-type precision from config', () => { + assert.equal(getPrecision('price'), 2); + assert.equal(getPrecision('amount'), 6); + assert.equal(getPrecision('smallNumbers'), 8); +}); + +test('getPrecision — unknown type falls back to default (2)', () => { + assert.equal(getPrecision('made-up-type'), 2); +}); + +test('getPrecision — accepts custom config and ignores defaults', () => { + const custom = { display: { price: 7, default: 5 } }; + assert.equal(getPrecision('price', custom), 7); + assert.equal(getPrecision('made-up-type', custom), 5); +}); diff --git a/auto-qa/tests/proposal-doc-store.test.mjs b/auto-qa/tests/proposal-doc-store.test.mjs new file mode 100644 index 0000000..220a6f1 --- /dev/null +++ b/auto-qa/tests/proposal-doc-store.test.mjs @@ -0,0 +1,267 @@ +/** + * proposalDocumentationStore spec mirror (auto-qa). + * + * Pins src/utils/proposalDocumentationStore.js — a singleton + * module-level store for "user-created proposal" demo data with an + * HTML→markdown converter. Currently has ZERO importers in the + * codebase (dead module). Pinned because: + * + * 1. Each call to setProposalDocumentationData triggers SEVEN + * module-level console.log lines (raw topics + per-topic before/ + * after + final combined + final stored). If anyone ever imports + * and uses this in production, the logs flood. Pinned as a + * hazard ratchet. + * 2. The convertToMarkdown rules are non-obvious (strips
    /
      , + * replaces
    1. with "- ",   with space, etc.). A regression + * that drops a rule would silently produce malformed markdown. + * 3. Hardcoded asset paths (/assets/futarchy-logo-black.svg, + * /assets/protocol-upgrade-banner.png) — must exist in public/ + * or any rendering caller would 404 on those images. + * 4. Module-level mutable state pattern (`let proposalDocumentationData = null`). + * Initial getter call returns null. Singleton pinned. + * + * Per the /loop directive: dead module is left as-is. The test + * documents the hazards so a future cleanup is deliberate. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const SRC = readFileSync( + new URL('../../src/utils/proposalDocumentationStore.js', import.meta.url), + 'utf8', +); + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +// --- spec mirror of the inner convertToMarkdown function --- +function convertToMarkdown(htmlContent, topicNumber) { + const sectionHeader = `### Section ${topicNumber}\n`; + const converted = htmlContent + .replace(//g, '') + .replace(/<\/h[1-6]>/g, '\n') + .replace(/
        /g, '') + .replace(/<\/ul>/g, '') + .replace(/
          /g, '') + .replace(/<\/ol>/g, '') + .replace(/
        1. /g, '- ') + .replace(/<\/li>/g, '\n') + .replace(/

          /g, '') + .replace(/<\/p>/g, '\n\n') + .replace(/ /g, ' ') + .trim(); + return `${sectionHeader}${converted}`; +} + +// --------------------------------------------------------------------------- +// convertToMarkdown — section header is always present +// --------------------------------------------------------------------------- + +test('convertToMarkdown — emits "### Section N" header before content', () => { + const r = convertToMarkdown('

          Hello

          ', 3); + assert.match(r, /^### Section 3\n/, + `each topic must be prefixed with "### Section \\n"`); +}); + +test('convertToMarkdown — empty input still emits the section header', () => { + const r = convertToMarkdown('', 1); + assert.equal(r, '### Section 1\n', + `empty content must still produce a header (no orphan content lines)`); +}); + +// --------------------------------------------------------------------------- +// convertToMarkdown — HTML tag stripping rules +// --------------------------------------------------------------------------- + +test('convertToMarkdown —

          ..

          open tags stripped, close → newline', () => { + for (const level of [1, 2, 3, 4, 5, 6]) { + // Use hTitle>

          x

          so the \n after + // sits BEFORE non-whitespace and survives trim(). + const r = convertToMarkdown(`Title

          x

          `, 1); + assert.match(r, /Title\n/, + `h${level} close must emit "Title\\n" before subsequent content`); + assert.doesNotMatch(r, new RegExp(``), + `h${level} open tag must be stripped`); + } +}); + +test('convertToMarkdown —
            /
              wrappers stripped entirely (open + close)', () => { + const r = convertToMarkdown('
              • One
              • Two
              ', 1); + assert.doesNotMatch(r, /<\/?ul>/, + `
                wrappers must be stripped`); + const r2 = convertToMarkdown('
                1. One
                ', 1); + assert.doesNotMatch(r2, /<\/?ol>/, + `
                  wrappers must be stripped`); +}); + +test('convertToMarkdown —
                1. opens with "- ", closes with newline', () => { + const r = convertToMarkdown('
                2. Item A
                3. Item B
                4. ', 1); + assert.match(r, /- Item A\n/); + // Last -> "\n" but trim() strips it. Match end-of-string. + assert.match(r, /- Item B$/); + assert.doesNotMatch(r, /<\/?li>/); +}); + +test('convertToMarkdown —

                  stripped on open,

                  → "\\n\\n"', () => { + const r = convertToMarkdown('

                  First

                  Second

                  ', 1); + assert.match(r, /First\n\n/); + // After trim, trailing \n\n on the LAST

                  is consumed. + assert.doesNotMatch(r, /<\/?p>/); +}); + +test('convertToMarkdown —   replaced with single space', () => { + const r = convertToMarkdown('

                  foo bar baz

                  ', 1); + assert.match(r, /foo bar baz/); + assert.doesNotMatch(r, / /); +}); + +test('convertToMarkdown — output is trimmed (no leading/trailing whitespace on body)', () => { + // The trim() applies to `converted`, BEFORE the section header is + // prepended. So the section header may sit alone, but no surrounding + // whitespace on the body itself. + const r = convertToMarkdown('

                  X

                  ', 1); + // Header is always "### Section 1\n", then the trimmed body. + // Body after replacements: " X\n\n " → trim → "X" + assert.equal(r, '### Section 1\nX', + `whitespace must be trimmed AFTER tag conversion`); +}); + +// --------------------------------------------------------------------------- +// convertToMarkdown — what it does NOT handle (silent corruption surface) +// --------------------------------------------------------------------------- + +test('convertToMarkdown — DOES NOT escape ', 1); + assert.match(r, /