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
7 changes: 3 additions & 4 deletions apps/web/bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
[test]
coverageThreshold = 0.65
coverageReporter = ["text", "lcov"]
coverageSkipTestFiles = true
coveragePathIgnorePatterns = ["register-dom.ts", "setup-tests.ts", "test/**", "src/shared/lib/format.ts"]
# Register the happy-dom global environment before any test file runs so
# React component tests have a DOM to render into.
preload = ["./test/happydom.ts"]
9 changes: 3 additions & 6 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test:coverage": "bun test --preload ./setup-tests.ts src/shared/lib src/features/trade/lib src/features/pools/lib --coverage",
"build": "vite build",
"preview": "vite preview",
"test": "bun test",
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit"
Expand All @@ -36,13 +37,9 @@
"zustand": "^5.0.4"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.10.6",
"@pythnetwork/hermes-client": "^3.1.0",
"@repo/vitest-config": "workspace:*",
"@happy-dom/global-registrator": "^20.11.1",
"@tanstack/eslint-config": "^0.3.0",
"@tanstack/react-query-devtools": "^5.101.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@tanstack/react-query-devtools": "^5.100.9",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.1.0",
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/app/config/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* apps/web/src/app/config/env.test.ts (issue #243)
*
* Proves startup validation fails loudly when a required VITE_ var is missing.
*
* Each scenario loads env.ts in its own fresh Bun subprocess (see
* test/config-harness.ts). That guarantees:
* - module imports are isolated per test (fresh module graph every time),
* - env vars set for one scenario never poison another test file, and
* - the module-load throw is captured exactly instead of aborting the suite.
*/

import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { VALID_ENV, loadEnv } from "../../../test/config-harness"

/** A full valid env with a single required key removed. */
function envWithout(key: keyof typeof VALID_ENV): Record<string, string> {
const env: Record<string, string> = { ...VALID_ENV }
delete env[key]
return env
}

describe("ENV startup validation (#243)", () => {
it("throws the exact error when VITE_NETWORK is missing", () => {
const result = loadEnv(envWithout("VITE_NETWORK"))
assert.equal(result.ok, false)
assert.equal(result.message, "Missing env var: VITE_NETWORK")
})

it("throws the exact error when VITE_RPC_URL is missing", () => {
const result = loadEnv(envWithout("VITE_RPC_URL"))
assert.equal(result.ok, false)
assert.equal(result.message, "Missing env var: VITE_RPC_URL")
})

it("throws the exact error when VITE_HORIZON_URL is missing", () => {
const result = loadEnv(envWithout("VITE_HORIZON_URL"))
assert.equal(result.ok, false)
assert.equal(result.message, "Missing env var: VITE_HORIZON_URL")
})

it("throws the exact error when a required contract ID is missing", () => {
const result = loadEnv(envWithout("VITE_CONTRACT_DATA_STORE"))
assert.equal(result.ok, false)
assert.equal(result.message, "Missing env var: VITE_CONTRACT_DATA_STORE")
})

it("loads successfully and derives values when every required var is present", () => {
const result = loadEnv(VALID_ENV)
assert.equal(result.ok, true)
assert.ok(result.env)
assert.equal(result.env.NETWORK, "testnet")
assert.equal(result.env.RPC_URL, VALID_ENV.VITE_RPC_URL)
assert.equal(result.env.HORIZON_URL, VALID_ENV.VITE_HORIZON_URL)
// ORACLE_URL is optional and falls back when unset.
assert.equal(result.env.ORACLE_URL, "https://arbitrum-api.gmxinfra.io")
})
})
58 changes: 58 additions & 0 deletions apps/web/src/app/config/network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* apps/web/src/app/config/network.test.ts (issue #244)
*
* Verifies network.ts derives the active config from ENV.
*
* Each variant loads network.ts in its own fresh Bun subprocess
* (see test/config-harness.ts) so the module cache — including the transitive
* env.ts — is reset between the testnet and mainnet variants.
*/

import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { Networks } from "@stellar/stellar-sdk"

import { VALID_ENV, loadNetwork } from "../../../test/config-harness"

describe("NETWORK derivation (#244)", () => {
it("derives the testnet config from env", () => {
const net = loadNetwork({
...VALID_ENV,
VITE_NETWORK: "testnet",
VITE_RPC_URL: "https://rpc.testnet.example",
VITE_HORIZON_URL: "https://horizon.testnet.example",
})

assert.equal(net.name, "testnet")
assert.equal(net.rpcUrl, "https://rpc.testnet.example") // from env
assert.equal(net.horizonUrl, "https://horizon.testnet.example") // from env
assert.equal(net.networkPassphrase, Networks.TESTNET)
assert.equal(net.explorerBaseUrl, "https://stellar.expert/explorer/testnet")
assert.equal(net.txUrl, "https://stellar.expert/explorer/testnet/tx/TXHASH")
assert.equal(
net.accountUrl,
"https://stellar.expert/explorer/testnet/account/GACCOUNT"
)
})

it("derives the mainnet config from env", () => {
const net = loadNetwork({
...VALID_ENV,
VITE_NETWORK: "mainnet",
VITE_RPC_URL: "https://rpc.mainnet.example",
VITE_HORIZON_URL: "https://horizon.mainnet.example",
})

assert.equal(net.name, "mainnet")
assert.equal(net.rpcUrl, "https://rpc.mainnet.example") // from env
assert.equal(net.horizonUrl, "https://horizon.mainnet.example") // from env
assert.equal(net.networkPassphrase, Networks.PUBLIC)
assert.equal(net.explorerBaseUrl, "https://stellar.expert/explorer/public")
assert.equal(net.txUrl, "https://stellar.expert/explorer/public/tx/TXHASH")
assert.equal(
net.accountUrl,
"https://stellar.expert/explorer/public/account/GACCOUNT"
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* ConfirmationDialog.test.tsx (issue #227)
*
* Verifies the confirmation dialog renders the order summary and wires the
* Confirm / Cancel callbacks. Uses semantic assertions (label→value lookups,
* roles, accessible names) — no snapshots.
*
* The dialog transitively imports the Soroban transaction layer (which loads
* config/env.ts and would throw without a full env, and must never fire a real
* transaction). Those modules are replaced with mocks; the pure formatting and
* pricing helpers are kept real so the assertions exercise production output.
* The heavy Base UI <Dialog> is stubbed with pass-through elements for speed.
*/

import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { cleanup, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import { formatUsd } from "../../lib/trade-math"

// ── Wallet: a connected account so the confirm path runs the order flow ──────
let walletAddress: string | null =
"GTESTACCOUNTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
mock.module("@/features/wallet/store/wallet-store", () => ({
useWalletStore: <T,>(selector: (state: { address: string | null }) => T): T =>
selector({ address: walletAddress }),
}))

// ── Fees: fixed, deterministic ───────────────────────────────────────────────
const TOTAL_FEES_USD = 12.34
mock.module("../../hooks/useTradeFees", () => ({
useTradeFees: () => ({
positionFeeUsd: 0,
priceImpactUsd: 0,
executionFeeUsd: 0,
totalFeesUsd: TOTAL_FEES_USD,
feesBreakdown: [],
}),
}))

// ── Transaction layer: mocked so no real transaction is ever submitted ───────
const createSwapOrder = mock(() => Promise.resolve({}))
const sendBatchOrderTxn = mock(() => Promise.resolve({}))
mock.module("../../lib/stellar", () => ({ createSwapOrder, sendBatchOrderTxn }))

mock.module("@/lib/soroban/simulate", () => ({
estimateFee: mock(() => Promise.resolve({ total: "0.5" })),
}))
mock.module("@/lib/contracts/exchange-router-client", () => ({
buildCreateOrderTransaction: mock(() => Promise.resolve({})),
buildBatchOrderTransaction: mock(() => Promise.resolve({})),
}))
mock.module("../../lib/order-encoding", () => ({
toCreateOrderParams: (o: unknown) => o,
toDecreaseOrderParams: (o: unknown) => o,
}))

// ── Base UI dialog: lightweight stand-ins (render children when open) ─────────
mock.module("@workspace/ui/components/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div role="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
}))

const { ConfirmationDialog } = await import("./ConfirmationDialog")

// ── Fixed order fixture: a 10x Long on BTC, collateral in USDC ───────────────
const SIZE_USD = 5000
const ENTRY_PRICE = 50_000
const LIQUIDATION_PRICE = 45_000

function makeTradeState() {
return {
tradeType: "Long",
tradeMode: "Market",
fromTokenAddress: "USDC",
toTokenAddress: "BTC",
marketAddress: "BTC-BTC-USDC",
collateralAddress: "USDC",
fromAmount: "1000",
triggerPrice: "",
leverage: 10,
sidecarOrders: [],
clearSidecarOrders: mock(() => {}),
tradeFlags: {
isLong: true,
isShort: false,
isSwap: false,
isPosition: true,
isMarket: true,
isLimit: false,
isTrigger: false,
},
}
}

type DialogProps = React.ComponentProps<typeof ConfirmationDialog>

function renderDialog(overrides: Partial<DialogProps> = {}) {
const onClose = mock(() => {})
render(
<ConfirmationDialog
open
onClose={onClose}
tradeState={makeTradeState() as unknown as DialogProps["tradeState"]}
sizeUsd={SIZE_USD}
entryPrice={ENTRY_PRICE}
liquidationPrice={LIQUIDATION_PRICE}
totalFeesUsd={TOTAL_FEES_USD}
{...overrides}
/>
)
return { onClose }
}

/** Read the value rendered next to a summary row label. */
function rowValue(label: string): string {
const labelEl = screen.getByText(label)
return labelEl.nextElementSibling?.textContent ?? ""
}

beforeEach(() => {
walletAddress = "GTESTACCOUNTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
createSwapOrder.mockClear()
sendBatchOrderTxn.mockClear()
})

afterEach(cleanup)

describe("ConfirmationDialog (#227)", () => {
it("renders the order summary from the fixture", async () => {
renderDialog()

// Fee estimation effect settles (uses mocked estimateFee).
await screen.findByText("~0.5 XLM")

const heading = screen.getByRole("heading")
// market + side are shown in the title.
expect(heading.textContent).toContain("BTC") // market
expect(heading.textContent).toContain("Long") // side

expect(rowValue("Size")).toBe(formatUsd(SIZE_USD)) // size
expect(rowValue("Collateral")).toBe("1000 USDC") // collateral
expect(rowValue("Total fees")).toBe(formatUsd(TOTAL_FEES_USD)) // estimated fees
expect(rowValue("Entry price")).toBe(formatUsd(ENTRY_PRICE)) // price
})

it("runs the order flow and closes when Confirm is clicked", async () => {
const user = userEvent.setup()
const { onClose } = renderDialog()
await screen.findByText("~0.5 XLM")

await user.click(screen.getByRole("button", { name: /Confirm Long/i }))

await waitFor(() => expect(sendBatchOrderTxn).toHaveBeenCalledTimes(1))
expect(onClose).toHaveBeenCalledTimes(1)
expect(createSwapOrder).not.toHaveBeenCalled()
})

it("closes without submitting when Cancel is clicked", async () => {
const user = userEvent.setup()
const { onClose } = renderDialog()
await screen.findByText("~0.5 XLM")

await user.click(screen.getByRole("button", { name: "Cancel" }))

expect(onClose).toHaveBeenCalledTimes(1)
expect(sendBatchOrderTxn).not.toHaveBeenCalled()
expect(createSwapOrder).not.toHaveBeenCalled()
})
})
Loading
Loading