Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .a.swp
Binary file not shown.
Binary file removed .capability.swp
Binary file not shown.
8 changes: 8 additions & 0 deletions .changelog/unreleased/security-audit-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: security
area: trade
pr: TBD
breaking: false
---

Fixed a crash when opening the order confirmation dialog, which now shows the active network; TanStack Start was updated to clear a critical server-side XSS advisory.
8 changes: 8 additions & 0 deletions .changelog/unreleased/security-remove-third-party-oracle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: security
area: trade
pr: TBD
breaking: false
---

Trade prices no longer come from an unowned third-party price proxy; Pyth Hermes is now the primary price source, with Binance and GMX as fallbacks.
Binary file removed .colored.swp
Binary file not shown.
Binary file removed .for.swp
Binary file not shown.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,6 @@ fix.md
test-results/
# Generated OG images
apps/docs/public/og/
.nitro-static/og/
.nitro-static/og/
# Editor swap files
*.swp
Binary file removed .has.swp
Binary file not shown.
Binary file removed .if.swp
Binary file not shown.
Binary file removed .prompt,.swp
Binary file not shown.
Binary file removed .terminal.swp
Binary file not shown.
Binary file removed .the.swp
Binary file not shown.
Binary file removed .uncomment.swp
Binary file not shown.
3 changes: 3 additions & 0 deletions apps/docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
# DX-050: version snapshots are reproducible via scripts/snapshot-version.ts
# and are not authored content — see that script's header comment.
content-versions/

# Lighthouse CI generated reports and assertion results
.lighthouseci/
32 changes: 14 additions & 18 deletions apps/docs/content/concepts/oracles.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Oracles
description: Where SO4 prices come from, the order the app falls back through sources, how staleness is judged, and what happens when a source fails.
updated: 2026-08-31
updated: 2026-10-15
status: beta
---

Expand All @@ -11,9 +11,8 @@ implemented in `apps/web/src/features/trade/lib/oracle.ts`; this page describes
what that file does today, not what the README summary says.

> The README and the DX1 content map describe the intended design as "Binance
> primary, GMX fallback". The shipped `fetchTokenPrices` puts the SO4 oracle
> service first and Pyth second, with Binance and GMX below them. This page
> follows the code.
> primary, GMX fallback". The shipped `fetchTokenPrices` puts Pyth first, with
> Binance and GMX below it. This page follows the code.

## Sources, in priority order

Expand All @@ -22,11 +21,10 @@ returns the first that produces at least one price:

| # | Source | Endpoint | Notes |
| - | ------ | -------- | ----- |
| 1 | **SO4 oracle service** | `oracle.biscotti-proxy-worker.workers.dev/prices` | The primary source. A hosted aggregator; its own comments say it combines Binance, Coinbase, and Pyth. Returns a `min` and `max` price per token, scaled by `10^30`, with a Unix-seconds timestamp. |
| 2 | **Pyth Hermes** | via `fetchPythAttestations` (`apps/web/src/features/trade/lib/pyth.ts`) | Used only if source 1 threw or returned nothing. Binance display prices are layered on top to fill any symbol Pyth did not return. |
| 3 | **Binance REST** | `api.binance.com/api/v3/ticker/bookTicker` | Bid becomes the min price, ask becomes the max price. |
| 4 | **GMX oracle** | `${ENV.ORACLE_URL}/prices/tickers` | Last networked source. Prices are decoded with GMX v2 scaling: `usdPrice × 10^(30 − tokenDecimals)`. |
| 5 | **Built-in dummy prices** | none | A hard-coded `DUMMY_PRICES` array. Reached only if every source above failed. Prices are static and wrong; the UI has no live data at this point. |
| 1 | **Pyth Hermes** | via `fetchPythAttestations` (`apps/web/src/features/trade/lib/pyth.ts`) | The primary source. Binance display prices are layered on top to fill any symbol Pyth did not return. |
| 2 | **Binance REST** | `api.binance.com/api/v3/ticker/bookTicker` | Used only if Pyth threw or returned nothing. Bid becomes the min price, ask becomes the max price. |
| 3 | **GMX oracle** | `${ENV.ORACLE_URL}/prices/tickers` | Last networked source. Prices are decoded with GMX v2 scaling: `usdPrice × 10^(30 − tokenDecimals)`. |
| 4 | **Built-in dummy prices** | none | A hard-coded `DUMMY_PRICES` array. Reached only if every source above failed. Prices are static and wrong; the UI has no live data at this point. |

The four tracked markets are BTC, ETH, XLM, and USDC, mapped from the test token
symbols `TWBTC`, `TETH`, `TXLM`, `TUSDC`.
Expand All @@ -37,8 +35,7 @@ Each source is wrapped in its own `try`/`catch`. The app moves to the next
source when the current one:

- throws — a network error, or a non-200 HTTP response, or
- returns an empty result — for source 1, an empty map; for source 2, no Pyth
attestations.
- returns an empty result — for source 1, no Pyth attestations.

There is no health scoring, no retry, and no circuit breaker. A source that
succeeds once is not preferred later; every call re-walks the list from the
Expand All @@ -63,7 +60,7 @@ last value it has with the red dot.

## Mark price versus execution price

Sources 1, 3, and 4 return a `min` and a `max` price — the two sides of a
Every networked source returns a `min` and a `max` price — the two sides of a
spread. The displayed mark price and the price used for position valuation and
the liquidation check are derived from these; which side is used depends on the
direction of the position and the operation. The exact selection lives in the
Expand All @@ -75,16 +72,16 @@ If you are integrating, read those before assuming a single price.

| Situation | What the code does today |
| --------- | ------------------------ |
| SO4 oracle service outage | Falls through to Pyth, then Binance, then GMX. |
| Pyth Hermes outage | Falls through to Binance, then GMX. |
| All networked sources fail | Serves the static `DUMMY_PRICES`. The values are placeholders and the staleness indicator will read stale. There is no banner telling the user the feed is dead — this is unhandled. |
| One source returns a price far from the others | Nothing. `fetchTokenPrices` takes the first source that answers; it does not cross-check sources for deviation. Deviation checks, if any, happen in the contract at execution time, not in the price feed. |
| Price older than 30 seconds | Marked `stale` in the UI. Trading is not blocked client-side. |

## Worked example

The SO4 oracle service returns `{ symbol: "TWBTC", min: 7.87e34, max: 7.871e34,
timestamp: 1_760_000_000 }`. `oracle.ts` divides by `1e30`, giving a min of
78,700 and a max of 78,710 USD, with `updatedAt = 1_760_000_000 × 1000` ms. If
Pyth Hermes returns a BTC attestation with a min of 78,700 and a max of
78,710 USD, published at Unix time 1,760,000,000 seconds, so `oracle.ts` sets
`updatedAt = 1_760_000_000 × 1000` ms. If
the current time is 1,760,000,020,000 ms, the price is 20 seconds old, so the
trade page shows a yellow `warning` dot. At 1,760,000,035,000 ms it is 35
seconds old and the dot turns red.
Expand All @@ -95,8 +92,7 @@ From source to liquidation check, the value flows like this:

```mermaid caption="Oracle price ingestion pipeline and liquidation check flow"
graph TD
SO4[SO4 oracle service] --> TP[TokenPrice<br/>minPrice, maxPrice, updatedAt, source]
Pyth[Pyth Hermes] --> TP
Pyth[Pyth Hermes] --> TP[TokenPrice<br/>minPrice, maxPrice, updatedAt, source]
Binance[Binance REST] --> TP
GMX[GMX oracle] --> TP
Dummy[DUMMY_PRICES] --> TP
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/lighthouserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"collect": {
"numberOfRuns": 3,
"startServerCommand": "bun run dev",
"startServerReadyPattern": "Listening on http://localhost:3000",
"startServerReadyPattern": "Listening on: http://localhost:3000",
"startServerReadyTimeout": 120000,
"url": [
"http://localhost:3000/",
"http://localhost:3000/concepts/risk",
Expand Down
6 changes: 3 additions & 3 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/bun": "^1.3.0",
"@vitest/coverage-v8": "^3.2.0",
"@vitest/expect": "^3.2.7",
"vitest": "3",
"@vitest/coverage-v8": "^4.1.12",
"@vitest/expect": "^4.1.12",
"vitest": "^4.1.12",
"vitest-axe": "^0.1.0",
"@types/mdx": "^2.0.14",
"@types/react": "^19.2.18",
Expand Down
7 changes: 6 additions & 1 deletion apps/s03-indexer/.github/workflows/cli-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,10 @@ jobs:
- name: repo
run: echo ${{github.repository}}
- name: Publish and Deploy
# Inputs go through env vars, not ${{ }} interpolation, so a crafted
# projectName cannot inject shell commands into this step.
env:
PROJECT_NAME: ${{ github.event.inputs.projectName }}
REPOSITORY: ${{ github.repository }}
run: |
sh .github/workflows/scripts/publish-deploy.sh -o ${{github.repository}} -p ${{github.event.inputs.projectName}} -e ${{secrets.ENDPOINT}}
sh .github/workflows/scripts/publish-deploy.sh -o "$REPOSITORY" -p "$PROJECT_NAME" -e "$ENDPOINT"
12 changes: 7 additions & 5 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"preview": "vite preview",
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:cache-headers": "bun test scripts/nitro-cache-headers.test.ts"
},
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^2.4.0",
Expand All @@ -21,7 +22,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-start": "^1.132.0",
"@tanstack/react-start": "~1.168.61",
"@tanstack/router-plugin": "^1.132.0",
"@workspace/ui": "workspace:*",
"lightweight-charts": "^5.2.0",
Expand All @@ -44,16 +45,17 @@
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/expect": "^3.2.7",
"@vitest/ui": "3",
"@vitest/expect": "^4.1.12",
"@vitest/ui": "^4.1.12",
"axe-core": "^4.12.1",
"eslint": "^9.39.2",
"happy-dom": "^20.10.6",
"msw": "^2.14.6",
"qrcode.react": "^4.2.0",
"rou3": "^1.0.0",
"typescript": "^5.9.3",
"vite": "^7.3.2",
"vitest": "3",
"vitest": "^4.1.12",
"vitest-axe": "^0.1.0"
}
}
103 changes: 103 additions & 0 deletions apps/web/scripts/nitro-cache-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Cache-headers integration check (OB-125 follow-up).
*
* Requires a production build first:
* bun run --cwd apps/web build
* bun run --cwd apps/web test:cache-headers
*
* Spawns the built Nitro server and verifies the route rules from
* src/shared/lib/cache-headers.ts against real HTTP responses:
* - hashed Vite assets under /assets (where fonts and images land) get the
* immutable cache header through directory rules — extension globs such as
* `/**\/*.woff2` are rejected by rou3/h3 2.x;
* - non-hashed public files keep revalidating instead of being pinned.
*
* Uses node:http rather than fetch because bunfig.toml preloads happy-dom,
* whose fetch enforces browser CORS rules.
*/
import { request } from "node:http"
import { readdir } from "node:fs/promises"
import { join } from "node:path"
import { afterAll, beforeAll, expect, test } from "bun:test"
import { spawn } from "bun"
import type { Subprocess } from "bun"

const PORT = 3212
const IMMUTABLE = "public, max-age=31536000, immutable"
const REVALIDATING = "public, no-cache, max-age=0, must-revalidate"

const appRoot = join(import.meta.dirname, "..")

let server: Subprocess

type HttpResponse = {
status: number
headers: Record<string, string | Array<string> | undefined>
}

function httpGet(path: string): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const req = request(
{ host: "localhost", port: PORT, path, method: "GET" },
(res) => {
res.resume()
res.on("end", () =>
resolve({ status: res.statusCode ?? 0, headers: res.headers }),
)
},
)
req.on("error", reject)
req.end()
})
}

async function findAsset(extension: string): Promise<string | null> {
const entries = await readdir(join(appRoot, ".output/public/assets"))
return entries.find((entry) => entry.endsWith(extension)) ?? null
}

beforeAll(async () => {
server = spawn(["node", join(appRoot, ".output/server/index.mjs")], {
env: { ...process.env, PORT: String(PORT) },
stdout: "ignore",
stderr: "ignore",
})

for (let i = 0; i < 100; i++) {
try {
await httpGet("/")
return
} catch {
await Bun.sleep(100)
}
}
throw new Error(
"built Nitro server did not start — run `bun run --cwd apps/web build` first",
)
})

afterAll(() => {
server.kill()
})

test("hashed .woff2 font requests get the immutable cache header", async () => {
const font = await findAsset(".woff2")
expect(font).not.toBeNull()

const res = await httpGet(`/assets/${font}`)
expect(res.status).toBe(200)
expect(res.headers["cache-control"]).toBe(IMMUTABLE)
})

test("hashed .png requests get the immutable cache header", async () => {
// This build emits no images, but the /assets/** rule covers everything
// Vite hashes, so a synthetic path exercises the same rule.
const res = await httpGet("/assets/token-icon-4f2a91.png")
expect(res.headers["cache-control"]).toBe(IMMUTABLE)
})

test("non-hashed public files stay revalidating", async () => {
const res = await httpGet("/favicon.ico")
expect(res.status).toBe(200)
expect(res.headers["cache-control"]).toBe(REVALIDATING)
})
42 changes: 21 additions & 21 deletions apps/web/setup-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,24 @@ afterAll(() => server.close());
process.env.VITE_NETWORK = 'testnet';
process.env.VITE_RPC_URL = 'https://soroban-testnet.stellar.org';
process.env.VITE_HORIZON_URL = 'https://horizon-testnet.stellar.org';
process.env.VITE_CONTRACT_EXCHANGE_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4';
process.env.VITE_CONTRACT_SYNTHETICS_READER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSE2';
process.env.VITE_CONTRACT_DATA_STORE = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSDS';
process.env.VITE_CONTRACT_ORDER_VAULT = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSOV';
process.env.VITE_CONTRACT_STAKING_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSSR';
process.env.VITE_CONTRACT_GLV_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSGR';
process.env.VITE_CONTRACT_VESTING_ROUTER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSVR';
process.env.VITE_CONTRACT_REFERRAL_STORAGE = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSRS';
process.env.VITE_CONTRACT_MARKET_FACTORY = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSMF';
process.env.VITE_CONTRACT_DEPOSIT_HANDLER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSDH';
process.env.VITE_CONTRACT_WITHDRAWAL_HANDLER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSWH';
process.env.VITE_CONTRACT_ORACLE = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSO';
process.env.VITE_CONTRACT_ORDER_HANDLER = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSOH';
process.env.VITE_FAUCET = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSF';
process.env.VITE_TOKEN_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTUSDC';
process.env.VITE_TOKEN_TWBTC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTWBTC';
process.env.VITE_TOKEN_TETH = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTETH';
process.env.VITE_TOKEN_TXLM = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTXLM';
process.env.VITE_MARKET_TOKEN_TWBTC_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMT1';
process.env.VITE_MARKET_TOKEN_TETH_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMT2';
process.env.VITE_MARKET_TOKEN_TXLM_TUSDC = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMT3';
process.env.VITE_CONTRACT_EXCHANGE_ROUTER = 'CAASUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7';
process.env.VITE_CONTRACT_SYNTHETICS_READER = 'CABCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSYU';
process.env.VITE_CONTRACT_DATA_STORE = 'CABSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABDKN';
process.env.VITE_CONTRACT_ORDER_VAULT = 'CACCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7WT';
process.env.VITE_CONTRACT_STAKING_ROUTER = 'CACSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOEK';
process.env.VITE_CONTRACT_GLV_ROUTER = 'CADCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB4TB';
process.env.VITE_CONTRACT_VESTING_ROUTER = 'CADSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABNBY';
process.env.VITE_CONTRACT_REFERRAL_STORAGE = 'CAECUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANKM';
process.env.VITE_CONTRACT_MARKET_FACTORY = 'CAESUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4YV';
process.env.VITE_CONTRACT_DEPOSIT_HANDLER = 'CAFCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABOP6';
process.env.VITE_CONTRACT_WITHDRAWAL_HANDLER = 'CAFSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB75H';
process.env.VITE_CONTRACT_ORACLE = 'CAGCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBZ';
process.env.VITE_CONTRACT_ORDER_HANDLER = 'CAGSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASTA';
process.env.VITE_FAUCET = 'CAHCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAEL';
process.env.VITE_TOKEN_TUSDC = 'CAHSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRWS';
process.env.VITE_TOKEN_TWBTC = 'CAICUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABATC';
process.env.VITE_TOKEN_TETH = 'CAISUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRB3';
process.env.VITE_TOKEN_TXLM = 'CAJCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADWQ';
process.env.VITE_MARKET_TOKEN_TWBTC_TUSDC = 'CAJSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASEJ';
process.env.VITE_MARKET_TOKEN_TETH_TUSDC = 'CAKCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABOYX';
process.env.VITE_MARKET_TOKEN_TXLM_TUSDC = 'CAKSUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB7KO';
17 changes: 11 additions & 6 deletions apps/web/src/features/changelog/hooks/use-release-hash.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ function TestRelease({ ready = true }: { ready?: boolean }) {
afterEach(() => {
window.history.replaceState(null, "", "/")
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

describe("useReleaseHash", () => {
it("scrolls to and focuses a direct release hash", () => {
window.history.replaceState(null, "", "/changelog#v0-4-0")
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
vi.spyOn(window, "matchMedia").mockReturnValue({
matches: false,
} as MediaQueryList)
// jsdom does not implement matchMedia, so stub it rather than spy on it.
vi.stubGlobal(
"matchMedia",
vi.fn().mockReturnValue({ matches: false }),
)

const { container } = render(<TestRelease />)
const release = container.querySelector("#v0-4-0")
Expand All @@ -35,9 +38,11 @@ describe("useReleaseHash", () => {
window.history.replaceState(null, "", "/changelog#v0-4-0")
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
vi.spyOn(window, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList)
// jsdom does not implement matchMedia, so stub it rather than spy on it.
vi.stubGlobal(
"matchMedia",
vi.fn().mockReturnValue({ matches: true }),
)

render(<TestRelease />)

Expand Down
Loading
Loading