diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index af3ad1281..000000000 --- a/.gitattributes +++ /dev/null @@ -1,4 +0,0 @@ -/.yarn/** linguist-vendored -/.yarn/releases/* binary -/.yarn/plugins/**/* binary -/.pnp.* binary linguist-generated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8848899f4..b812f6b7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,48 @@ jobs: - run: pnpm test - run: pnpm check-hygiene + + production-consumer-smoke: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check smoke API key + id: smoke-key + env: + STAKEKIT_SMOKE_API_KEY: ${{ secrets.STAKEKIT_SMOKE_API_KEY }} + run: | + if [ -n "$STAKEKIT_SMOKE_API_KEY" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping production-consumer smoke test because STAKEKIT_SMOKE_API_KEY is unavailable." + fi + + # actions/checkout@v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + if: steps.smoke-key.outputs.available == 'true' + + - name: Install mise + if: steps.smoke-key.outputs.available == 'true' + run: | + curl https://mise.run | MISE_VERSION=v2026.5.6 sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" + mise install + + - if: steps.smoke-key.outputs.available == 'true' + run: pnpm install --frozen-lockfile + + - name: Install Chromium + if: steps.smoke-key.outputs.available == 'true' + run: pnpm -F @stakekit/widget exec playwright install --with-deps chromium + + - name: Run production-consumer smoke test + if: steps.smoke-key.outputs.available == 'true' + env: + VITE_API_KEY: ${{ secrets.STAKEKIT_SMOKE_API_KEY }} + NEXT_PUBLIC_API_KEY: ${{ secrets.STAKEKIT_SMOKE_API_KEY }} + run: pnpm test:smoke diff --git a/.gitignore b/.gitignore index 7eec523be..33b628965 100644 --- a/.gitignore +++ b/.gitignore @@ -1,268 +1,47 @@ -# production -/build - -# misc -.DS_Store -*.pem -.idea - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local +# Dependencies and package-manager state +node_modules/ +.pnpm-store/ +*.tgz -# typescript +# Build output and caches +build/ +dist/ +.next/ +out/ +.turbo/ +.cache/ +coverage/ +.nyc_output/ +playwright-report/ +test-results/ +*.lcov *.tsbuildinfo next-env.d.ts +# Environment and local configuration +.env +.env.* +!.env.example +*.local -# compiled output -lib -**/lib - -# OS -.DS_Store - -# Tests -coverage -**/coverage -.nyc_output - -.data -ormconfig.json - -## Terraform -**/.terraform/**/ - -.terraform.lock.hcl -.terraform -# .tfstate files -*.tfstate -*.tfstate.* -*.tfvars - -# Logs -logs +# Logs and diagnostics +logs/ *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) +*-debug.log* report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -*.lcov - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Swap the comments on the following lines if you don't wish to use zero-installs -# Documentation here: https://yarnpkg.com/features/zero-installs -!.yarn/cache -#.pnp.* - -# Yarn Not-Zero-Installs -# https://yarnpkg.com/getting-started/qa/\#which-files-should-be-gitignored -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions -.turbo - +# Editors and operating systems .DS_Store - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files +.idea/ .vscode/* !.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln +!.vscode/settings.json *.sw? - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts - +# Credentials and certificates *.key *.pem *.p12 *.pfx *.crt -*.cer - -.pnpm-store \ No newline at end of file +*.cer \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..dbe51e06c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +# Pinned to effect@4.0.0-rc.112 (matches widget catalog effect version). +[submodule ".repos/effect"] + path = .repos/effect + url = https://github.com/Effect-TS/effect.git diff --git a/.repos/effect b/.repos/effect new file mode 160000 index 000000000..2600f62f4 --- /dev/null +++ b/.repos/effect @@ -0,0 +1 @@ +Subproject commit 2600f62f4532026928454dcea8d1c48557b3f942 diff --git a/.rev-dep.config.jsonc b/.rev-dep.config.jsonc deleted file mode 100644 index 5691d0bec..000000000 --- a/.rev-dep.config.jsonc +++ /dev/null @@ -1,60 +0,0 @@ -{ - "$schema": "https://github.com/jayu/rev-dep/blob/master/config-schema/1.7.schema.json?raw=true", - "configVersion": "1.7", - "rules": [ - { - "path": "packages/widget", - "prodEntryPoints": [ - "src/index.package.ts", - "src/index.bundle.ts", - "src/main.tsx", - "src/translation/i18next.d.ts", - "src/types/purify-extend.d.ts", - "src/types/window.d.ts", - "src/vite-env.d.ts", - "postcss.config.js", - "public/mockServiceWorker.js", - "vite/*.ts" - ], - "devEntryPoints": [ - "scripts/generate-effect-openapi.ts", - "tests/utils/setup.ts", - "tests/**/*.test.ts", - "tests/**/*.test.tsx" - ], - "orphanFilesDetection": { - "enabled": true - }, - "unusedExportsDetection": { - "enabled": true, - "ignoreFiles": ["src/types/yield-api-schema.d.ts"] - }, - "unusedNodeModulesDetection": { - "enabled": true, - "excludeModules": [ - "@effect/openapi-generator", - "@effect/platform-node", - "babel-plugin-react-compiler", - "playwright", - "postcss", - "swagger2openapi", - "tsx", - "yaml" - ], - "pkgJsonFieldsWithBinaries": ["scripts"] - }, - "missingNodeModulesDetection": { - "enabled": true - }, - "unresolvedImportsDetection": { - "enabled": true - }, - "circularImportsDetection": { - "enabled": true - } - // "devDepsUsageOnProdDetection": { - // "enabled": true - // } - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json index 49e9f9c6e..80b46de3a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,21 +1,16 @@ { - "typescript.preferences.importModuleSpecifier": "relative", + "js/ts.preferences.importModuleSpecifier": "relative", + "js/ts.preferences.autoImportFileExcludePatterns": [".repos/**"], "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit" }, - - "typescript.preferences.autoImportFileExcludePatterns": ["repos/**"], - "javascript.preferences.autoImportFileExcludePatterns": ["repos/**"], - "files.exclude": { - "repos/**": true + ".repos/**": true }, - "files.watcherExclude": { - "repos/**": true + ".repos/**": true }, - "search.exclude": { - "repos/**": true + ".repos/**": true } } diff --git a/AGENTS.md b/AGENTS.md index 57d739464..9d049bcbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,62 +1,62 @@ -# StakeKit Widget - Agent Guide - -## Project Overview -- Monorepo managed with `pnpm` workspaces + Turborepo. -- Main package is `@stakekit/widget` in `packages/widget` (React + TypeScript + Vite). -- Widget supports two entry modes: - - React component export (`src/index.package.ts`) - - Fully bundled renderer (`src/index.bundle.ts`) -- Runtime branches between classic widget and dashboard variant in `src/App.tsx`. - -## Repo Layout (important paths) -- `packages/widget/src/App.tsx` — root app, router setup, bundle renderer. -- `packages/widget/src/Widget.tsx` — non-dashboard route flow (earn/review/steps/details). -- `packages/widget/src/Dashboard.tsx` + `pages-dashboard/*` — dashboard variant UI. -- `packages/widget/src/providers/*` — global provider composition (API, query, wallet, tracking, theme, stores). -- `packages/widget/src/hooks/*` — feature and API hooks. -- `packages/widget/src/domain/*` — shared domain types/helpers. -- `packages/widget/src/translation/*` — i18n resources (`English`, `French`). -- `packages/widget/tests/*` — Vitest browser tests (MSW-backed). -- `packages/examples/*` — integration examples (`with-vite`, `with-vite-bundled`, `with-nextjs`, `with-cdn-script`). - -## Commands Agents Should Use - -### From repo root (all workspaces via Turbo) -- `pnpm build` — build all packages. -- `pnpm lint` — lint/type-check all packages. -- `pnpm test` — run all workspace tests. -- `pnpm format` — run formatting checks/tasks. -- `pnpm check-hygiene` — check unused deps, unresolved imports, circular deps, etc. - -### Focused widget commands (recommended for most tasks) -- `pnpm --filter @stakekit/widget {command}` - -## Agent Working Guidelines (short) -- Keep public API compatibility in `src/index.package.ts` and `src/index.bundle.ts`. -- React Compiler is enabled. Do not add `useMemo`, `useCallback`, or `React.memo` only for render-performance optimization; prefer plain values/functions. -- When changing user-facing copy, update both: - - `packages/widget/src/translation/English/translations.json` - - `packages/widget/src/translation/French/translations.json` -- After changes, run the lint command to check lint and type errors. - -## Useful Context for Debugging -- API client is configured in `packages/widget/src/providers/api/api-client-provider.tsx`. -- React Query defaults are in `packages/widget/src/providers/query-client/index.tsx`. -- App-level config/env mapping is in `packages/widget/src/config/index.ts`. -- Test bootstrapping + MSW worker setup: - - `packages/widget/tests/utils/setup.ts` - - `packages/widget/tests/mocks/worker.ts` - -## Vendored Repositories - -This project vendors external repositories under `@repos/`. - -- Use vendored repositories as read-only reference material when working with related libraries -- Prefer examples and patterns from the vendored source code over generated guesses or web search results -- Do not edit files under `@repos/` unless explicitly asked -- Do not import from `@repos/` - application code should continue importing from normal package dependencies -- `@repos/effect` is a local-only clone of Effect-TS/effect-smol and may be ignored by Git locally -- When searching `@repos/`, use `rg --no-ignore @repos/` so ignored local reference repositories are included without searching unrelated ignored directories -- Before writing any Effect code, inspect `@repos/effect/LLMS.md` -- Before writing code that interacts with Effect `HttpClient`, inspect `agent-patterns/effect-http-client.md` -- Before writing code that uses Effect `Stream`, inspect `agent-patterns/effect-stream.md` +# StakeKit Widget: Agent Guide + +This is a pnpm-workspace/Turborepo monorepo. The main package is +`@stakekit/widget` in `packages/widget` (React, TypeScript, and Vite), published +as both a React component and a bundled browser renderer. + +This file is operational guidance. The code and executable checks are the +source of truth for behavior. + +## Read before changing code + +| Work | Read | +| --- | --- | +| Ownership, dependency direction, and Module interfaces | `packages/widget/ARCHITECTURE.md` | +| Project vocabulary | `CONTEXT.md` | +| A decision that constrains the change | The relevant current file in `docs/adr/` | +| Effect APIs | Run `git submodule update --init .repos/effect` (pinned to the same release as the catalog `effect` version, currently `effect@4.0.0-rc.112`), then read `.repos/effect/LLMS.md`, then the exact upstream source or tests | +| Local issues, domain docs, and triage | `docs/agents/` | +| Publishing | `docs/releases.md` | + +## Commands + +Run pnpm through the version pinned by mise: `mise exec -- pnpm ...`. + +- `pnpm --filter @stakekit/widget lint` — widget formatting and type checks. +- `pnpm --filter @stakekit/widget test:unit` — Node tests. +- `pnpm --filter @stakekit/widget test:dom` — jsdom tests. +- `pnpm --filter @stakekit/widget test:browser` — Chromium tests. +- `pnpm check-hygiene` — dependency-cruiser and Knip; run after changing the import graph. +- `pnpm lint` — all workspaces plus the root ast-grep rules. +- `pnpm check` — lint, hygiene, tests, and builds. +- `pnpm test:smoke` — built package consumption; requires a configured API key. + +Install dependencies only when manifests, the lockfile, or the task require it. +Use the normal pnpm store rather than creating a project-local store. + +Test project selection is filename-based: `*.browser.test.*` uses Chromium, +`*.dom.test.*` uses jsdom, and other tests run in Node. + +## Working rules + +- Treat architecture-check failures as design feedback. Do not add blanket or + unused suppressions. +- React Compiler is enabled. Do not add `useMemo`, `useCallback`, or `React.memo` + for render performance. +- Avoid nested ternaries and mutable bindings used to select values. +- Update English and French translation files together for user-facing copy. +- Treat `.repos/` as read-only reference material and never import from it. + +## Documentation + +- Before changing domain vocabulary or ADRs, follow `docs/agents/domain.md`. +- Before creating or updating local specifications, issues, or triage state, + follow `docs/agents/issue-tracker.md` and `docs/agents/triage-labels.md`. + +## Before finishing + +Run the smallest relevant tests while working. Before handoff, run +`mise exec -- pnpm --filter @stakekit/widget lint`; also run +`mise exec -- pnpm check-hygiene` whenever modules moved, were added or deleted, +or their dependency edges changed. Scale up to `pnpm check` for broad or risky +changes. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..2ac7efedc --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,237 @@ +# StakeKit Widget Domain Glossary + +Use these names in code, tests, issues, and discussions. This file defines +project vocabulary, not implementation structure. + +## Embedding + +**Widget Instance**: A mounted StakeKit Widget within a browser document. A +document may contain one Widget Instance at a time; sequential instances are +supported. + +**Application Runtime Generation**: The continuous application-state lifetime +from a Widget Instance's mount through its unmount. + +**Host Configuration**: The typed values supplied by the embedding host. + +**Widget Configuration**: The normalized, authoritative current configuration +derived from Host Configuration. + +**Application API Identity**: The current normalized API key and endpoint set. + +**Widget Domain Event**: An immutable fact that a meaningful occurrence +completed. It carries identity for observers but does not prescribe a mutation. + +**Widget Maintenance**: The application-wide state in which new user journeys +are unavailable. + +## Wallet + +**Network**: A canonical blockchain or protocol network identity supported +across the Widget's shared product data. + +**Enabled Wallet Networks**: The active project Networks reported by Yield and +narrowed to Wallet Networks when Wallet Bootstrap begins. + +**Wallet Network**: A Network on which the Widget can construct and operate a +wallet connection. + +**Protocol Chain Identity**: A Network's native identity within its protocol, +such as an EVM numeric chain ID, Cosmos string chain ID, or Substrate genesis +hash. + +**Wallet Routing ID**: The identifier wallet integrations use to select a +Wallet Network. It may be protocol-native or assigned by the Widget. + +**External Provider Chain ID**: A numeric Wallet Routing ID accepted by an +External Provider. It exists only for the External Provider's supported Wallet +Networks. + +**Wallet Runtime**: Wallet capabilities and state belonging to one Application +Runtime Generation. + +**Wallet Bootstrap**: The one-time establishment of Wallet Topology and initial +Wallet State. The Wallet Runtime is unavailable until it completes. + +**Wallet Bootstrap Snapshot**: The immutable inputs captured together when +Wallet Bootstrap begins. + +**Wallet Topology**: The network set, Connector Mode, and connector-construction +policy fixed by the Wallet Bootstrap Snapshot. + +**Wallet Policy**: Host Configuration that filters, orders, and groups the +wallets available within the fixed Wallet Topology. + +**Connector Mode**: The mutually exclusive source of connectors selected at +Wallet Bootstrap. + +**Wallet State**: The authoritative current connection, account, network, and +connector details owned by the wallet service. + +**Wallet Scope**: An immutable execution snapshot of a Wallet Scope Owner and +relevant additional addresses. It can remain available while Wallet State is +connecting. + +**Wallet Scope Owner**: A Wallet Scope's network and primary address. EVM +addresses compare case-insensitively; additional addresses are not owner +identity. + +**Wallet Command Context**: The wallet-routing snapshot captured when a command +begins. A started command retains it even if current Wallet State changes. + +**External Provider Snapshot**: The latest host-supplied external wallet +identity, supported networks, and operations. + +## Tokens and amounts + +**Token Identity**: Canonical network, exact symbol, and contract address when +present. EVM addresses compare case-insensitively; non-EVM addresses compare +exactly. + +**Exact Token Amount**: A lossless quantity in token units at no finer precision +than the token's decimals. Eligibility and Action Commands use this value. + +**Base Unit Amount**: An integer count of a token's smallest indivisible unit. +Wallet transactions and raw balances use this value. + +## Earn + +**Earn Catalog**: The authoritative project-enabled, enterable categories, +tokens, yields, and validators under the current filters. + +**Earn Selection**: The category, token, yield, validators, and entry values +currently resolved for an Earn journey. + +**Earn Selection Reconciliation**: The deterministic resolution of the current +Earn Selection from prior selection, Entry Intent, initialization, preferences, +Wallet Scope Owner, and Earn Catalog. + +**Earn Initialization**: The one-time capture of host or deep-link parameters +for the first Earn Selection after Wallet Bootstrap settles. + +**Earn Readiness**: The condition in which every authoritative fact required by +the resolved Earn path has a usable value. + +**Yield Entry**: A user's pre-execution attempt to add tokens to an Earn +Selection. + +**Yield Entry Readiness**: The closed availability state Loading, Empty, +Ineligible, Refreshing, or Ready supplied by an entry surface. + +**Unknown Earn Balance**: Absence of usable balance data for an Earn token. It +is distinct from a known zero balance. + +**Validator Policy**: Host Configuration that constrains eligible or preferred +validators for a network. + +**Eligible Validator**: A validator satisfying the selected yield's +requirements and current Validator Policy. + +**Yield Reward Rate**: The current rate advertised for a yield, independent of +validator choice. + +**Validator Reward Rate**: The current rate belonging to one Eligible Validator. + +**Effective Reward Rate**: The rate for the current Earn Selection: the +unweighted mean of selected validators with rates, otherwise the Yield Reward +Rate. + +**Reward Rate History**: A yield-scoped time series of Yield Reward Rate +snapshots, not validator or Effective Reward Rates. + +## Borrow + +**Borrow Network**: A Wallet Network on which the Widget offers Borrow markets +and can prepare Borrow actions. + +**Borrow Entry**: The journey for selecting a market, entering borrow and +collateral amounts, and preparing a new or expanded Market Position. + +**Borrow Account Snapshot**: The decoded provider snapshot of one wallet +owner's balances and risk facts for an integration and network. + +**Market Position**: An existing Borrow position in one market, including its +balances, pending actions, local metrics, and governing Risk Position. + +**Market Debt**: Debt attributed to one Market Position, even when account-wide +risk governs the pool. + +**Borrow Positions**: The Wallet Scope aggregate of Market Positions and the +resolver for the Risk Position governing a catalog market. + +**Risk Position**: The solvency view for either a pooled account or isolated +market, including semantic projections for proposed changes. + +**Account Risk Position**: A Risk Position shared by pooled Market Positions for +one integration and network. + +**Market Risk Position**: A Risk Position owned by one isolated market. + +**Risk Unavailable**: A typed absence of consistent inputs for a risk +projection. It is not a warning, blocker, or substitute metric. + +**Borrow Constraint Warning**: A known liquidity, balance, debt-minimum, or +capacity violation attached to a constructible Action Command for review. The +provider or blockchain remains authoritative at execution. + +## Errors and portfolio + +**API Request Failure**: A normalized failure of one Effect API request. + +**Rich Error**: A schema-validated, presentable API failure detail. + +**Rich Error Identity**: A stable API error name used to find Error Copy; it is +not user-facing text. + +**Error Copy**: The user-facing title, details, and solution for one Rich Error +Identity. + +**Remote Error Catalog**: The hosted collection of Error Copy keyed by Rich +Error Identity. + +**Local Error Fallback**: Widget-owned Error Copy used when the Remote Error +Catalog lacks an identity. + +**Portfolio Completeness**: Whether every position source active for the current +Wallet Scope has usable data. An incomplete portfolio may retain positions but +is neither empty nor fully known. + +## Transactions + +**Entry Intent**: User-authored pre-execution values retained for an active entry +surface and Wallet Scope Owner. + +**Transaction Flow**: A Wallet Scope-bound journey through Review, Steps, and +Complete. Classic and Borrow flows share lifecycle language, not implementation. + +**Flow Session**: One user attempt to complete a Transaction Flow, with immutable +intake and Wallet Scope from Review through Complete. + +**Action Argument Contract**: The authoritative required and optional inputs for +one action. + +**Action Command**: A prepared instruction describing the protocol action the +user intends before the action is created. + +**Action Preview**: A freshly prepared action candidate inspected during Review. + +**Yield Action**: The created yield action and transactions required to carry +out an Action Command. + +**Execution Attempt**: The Steps-and-Complete portion of a Flow Session for one +reviewed action. + +**Transaction Workflow**: One execution of a prepared transaction plan, +including signing, submission, confirmation, advancement, retry, and completion. + +**Post-Transaction Reconciliation**: A Wallet Scope Owner-bound refresh of +authoritative balances and positions after a Transaction Workflow ends. + +**Continuable Yield Action**: A Yield Action with `WAITING_FOR_NEXT` status that +was created less than 168 hours ago. + +**Yield Action Continuation**: A Classic Transaction Flow confirmed from the +Activity details Review for a Continuable Yield Action. It executes the existing +Yield Action without creating an Action Preview or another Yield Action. Activity +owns the feed, action lookup, details, and variant layouts. Completed and failed +details never create a Flow Session. diff --git a/README.md b/README.md index cd1271736..846c2c368 100644 --- a/README.md +++ b/README.md @@ -1,752 +1,114 @@ -# Widget +# StakeKit Widget -Widget is a component that you can embed in your website with few lines of code. It allows your users to stake their crypto assets and earn rewards. +StakeKit Widget is an embeddable React interface for staking, yield, and +borrowing journeys. It is published as both a React component and an imperative +browser renderer. -Widget is mainly built as a React component and can be easily added in your application by importing it. There is also an option to use fully bundled widget component which can be added in any javascript library. If your application is already using React, using it as a React component will reduce bundle size of your application. If not, there is option for fully bundled component. - -## Development - -Create `.env.development.local` file and add variables from `.env.example`. For production builds, add `.env.production.local` file - -## Installation - -To install Widget: - -```bash -npm install @stakekit/widget - -yarn add @stakekit/widget +## Install +```sh pnpm add @stakekit/widget ``` -## Usage +React 18 or newer is required when using the component entrypoint. -To use Widget, first you'll need API key from Yield.xyz. - -## React component usage - -After you get the API key, you can import styles and widget component: +## React ```tsx -import "@stakekit/widget/package/css"; -import { SKApp, darkTheme } from "@stakekit/widget"; +import "@stakekit/widget/style.css"; +import { darkTheme, SKApp } from "@stakekit/widget"; -const App = () => { - return ; -}; +export function Staking() { + return ; +} ``` -## Bundled component usage +In Next.js, render `SKApp` from a client component. See the +[`with-vite`](packages/examples/with-vite) and +[`with-nextjs`](packages/examples/with-nextjs) examples for complete hosts. -```ts -import "@stakekit/widget/bundle/css"; -import { renderSKWidget, lightTheme, darkTheme } from "@stakekit/widget/bundle"; +## Browser renderer -const { rerender } = renderSKWidget({ - container: document.getElementById("sk_widget_container")!, - apiKey: "your-api-key", - theme: lightTheme, -}); +Use the bundled entrypoint when the host does not render React components: -rerender({ +```ts +import "@stakekit/widget/style.css"; +import { darkTheme, renderSKWidget } from "@stakekit/widget/bundle"; + +const widget = renderSKWidget({ + container: document.querySelector("#stakekit-widget")!, apiKey: "your-api-key", theme: darkTheme, -}) // pass new props here -``` - -## Params - -To open the widget on a specific yield opportunity you need to pass the parameter yieldId as a prop to the component. Below is are examples that will open the widget on theethereum-eth-lido-staking. All possible yieldIds can be retrieved from the /v1/stake/opportunities endpoint - -### React component - -```tsx -import "@stakekit/widget/package/css"; -import { SKApp, darkTheme } from "@stakekit/widget"; - -const App = () => { - return ( - - ); -}; -``` - -### Options - -```tsx -type SettingsProps = { - apiKey: string; - baseUrl?: string; - theme?: ThemeWrapperTheme; - tracking?: { - trackEvent?: (event: TrackEventVal, properties?: Properties) => void; - trackPageView?: (page: TrackPageVal, properties?: Properties) => void; - }; - onMountAnimationComplete?: () => void; - externalProviders?: SKExternalProviders; - disableGasCheck?: boolean; - hideNetworkLogo?: boolean; - disableInitLayoutAnimation?: boolean; - disableResizingInputFontSize?: boolean; - disableAutoScrollToTop?: boolean; - language?: Languages; - disableInjectedProviderDiscovery?: boolean; - mapWalletFn?: Parameters[0]["mapWalletFn"]; - mapWalletListFn?: Parameters[0]["mapWalletListFn"]; - customTranslations?: RecursivePartial; - tokensForEnabledYieldsOnly?: boolean; - validatorsConfig?: { - [Key in SupportedSKChains]?: { - allowed?: string[]; - blocked?: string[]; - preferred?: string[]; - mergePreferredWithDefault?: boolean; - }; - }; - tokenIconMapping?: - | Record - | ((token: TokenDto) => string); - chainIconMapping?: - | Record - | ((chain: SupportedSKChains) => string); - dashboardVariant?: boolean; - dashboardYieldCategoryOrder?: DashboardYieldCategory[]; - hideChainSelector?: boolean; - hideAccountAndChainSelector?: boolean; - preferredTokenYieldsPerNetwork?: PreferredTokenYieldsPerNetwork; - portalContainer?: HTMLElement; -}; -``` - -### Dashboard category tab order - -For the dashboard variant, you can reorder the `RWA`, `DeFi`, and `Stake` -category tabs. `Manage` and `Activity` stay fixed after those category tabs. - -```tsx -import "@stakekit/widget/package/css"; -import { DashboardYieldCategory, SKApp, darkTheme } from "@stakekit/widget"; - -const App = () => { - return ( - - ); -}; -``` - -### Override Icons - -You can override token or chain icons in widget - -```tsx -tokenIconMapping?: - | Record - | ((token: TokenDto) => string); -chainIconMapping?: - | Record - | ((chain: SupportedSKChains) => string); -``` - -### Wallet connectors mapping - -You can customize list of displayed wallet connectors. - -```tsx -import { createWallet } from '@stakekit/widget' +}); -mapWalletListFn: (walletList) => [ - { - groupName: "Primary", - wallets: [ - createWallet({ - id: "foo-bar", - name: "Foo Bar", - iconUrl: "https://foo-bar.com/icon.png", - iconBackground: "#fff", - downloadUrls: { - desktop: "https://foo-bar.com/download", - mobile: "https://foo-bar.com/download", - browserExtension: "https://foo-bar.com/download", - }, - createConnector: fooBarCreateConnector(), - }), - ], - }, -] +widget.rerender({ apiKey: "your-api-key", theme: darkTheme }); +widget.unmount(); ``` -After this is done, you can start using the widget. - -## Style customization +Only one Widget Instance may be mounted in a browser document at a time. +Unmounting it before mounting another instance is supported. -You can customize look of widget by overriding `darkTheme` or `lightTheme`, or providing your own theme and passing it to Widget. If theme is not provided, widget will use default `lightTheme`. +## Configuration -```tsx -import "@stakekit/widget/package/css"; -import { SKApp } from "@stakekit/widget"; - -const App = () => { - return ( - - ); -}; -``` - -```tsx -import "@stakekit/widget/package/css"; -import { SKApp, darkTheme } from "@stakekit/widget"; - -const App = () => { - return ( - - ); -}; -``` - -#### Theme properties: +`SKAppProps` and `BundledSKWidgetProps` are the source of truth for supported +configuration. Import those types from the same entrypoint as the integration: ```ts -{ - color: { - white: string; - black: string; - transparent: string; - primary: string; - accent: string; - disabled: string; - text: string; - textMuted: string; - textDanger: string; - background: string; - backgroundMuted: string; - tokenSelectBackground: string; - tokenSelectHoverBackground: string; - tokenSelect: string; - skeletonLoaderBase: string; - skeletonLoaderHighlight: string; - tabBorder: string; - stakeSectionBackground: string; - dropdownBackground: string; - selectValidatorMultiSelectedBackground: string; - selectValidatorMultiDefaultBackground: string; - warningBoxBackground: string; - positionsSectionBackgroundColor: string; - positionsSectionBorderColor: string; - positionsSectionDividerColor: string; - positionsClaimRewardsBackground: string; - positionsActionRequiredBackground: string; - positionsPendingBackground: string; - modalOverlayBackground: string; - modalBodyBackground: string; - tooltipBackground: string; - primaryButtonColor: string; - primaryButtonBackground: string; - primaryButtonOutline: string; - primaryButtonHoverColor: string; - primaryButtonHoverBackground: string; - primaryButtonHoverOutline: string; - primaryButtonActiveColor: string; - primaryButtonActiveBackground: string; - primaryButtonActiveOutline: string; - secondaryButtonColor: string; - secondaryButtonBackground: string; - secondaryButtonOutline: string; - secondaryButtonHoverColor: string; - secondaryButtonHoverBackground: string; - secondaryButtonHoverOutline: string; - secondaryButtonActiveColor: string; - secondaryButtonActiveBackground: string; - secondaryButtonActiveOutline: string; - smallButtonColor: string; - smallButtonBackground: string; - smallButtonOutline: string; - smallButtonHoverColor: string; - smallButtonHoverBackground: string; - smallButtonHoverOutline: string; - smallButtonActiveColor: string; - smallButtonActiveBackground: string; - smallButtonActiveOutline: string; - smallLightButtonColor: string; - smallLightButtonBackground: string; - smallLightButtonOutline: string; - smallLightButtonHoverColor: string; - smallLightButtonHoverBackground: string; - smallLightButtonHoverOutline: string; - smallLightButtonActiveColor: string; - smallLightButtonActiveBackground: string; - smallLightButtonActiveOutline: string; - disabledButtonColor: string; - disabledButtonBackground: string; - disabledButtonOutline: string; - disabledButtonHoverColor: string; - disabledButtonHoverBackground: string; - disabledButtonHoverOutline: string; - disabledButtonActiveColor: string; - disabledButtonActiveBackground: string; - disabledButtonActiveOutline: string; - } & { - connectKit: { - accentColor: string; - accentColorForeground: string; - actionButtonBorder: string; - actionButtonBorderMobile: string; - actionButtonSecondaryBackground: string; - closeButton: string; - closeButtonBackground: string; - connectButtonBackground: string; - connectButtonBackgroundError: string; - connectButtonInnerBackground: string; - connectButtonText: string; - connectButtonTextError: string; - connectionIndicator: string; - downloadBottomCardBackground: string; - downloadTopCardBackground: string; - error: string; - generalBorder: string; - generalBorderDim: string; - menuItemBackground: string; - modalBackdrop: string; - modalBackground: string; - modalBorder: string; - modalText: string; - modalTextDim: string; - modalTextSecondary: string; - profileAction: string; - profileActionHover: string; - profileForeground: string; - selectedOptionBorder: string; - standby: string; - }; - }; - fontSize: { - xs: string; - sm: string; - md: string; - lg: string; - lgx: string; - xl: string; - "2xl": string; - "3xl": string; - "4xl": string; - "5xl": string; - "6xl": string; - }; - letterSpacing: { - tighter: string; - tight: string; - normal: string; - wide: string; - wider: string; - widest: string; - }; - lineHeight: { - none: string; - shorter: string; - short: string; - base: string; - tall: string; - taller: string; - xs: string; - sm: string; - md: string; - lg: string; - xl: string; - "2xl": string; - "3xl": string; - "4xl": string; - "5xl": string; - "6xl": string; - }; - fontWeight: { - normal: string; - medium: string; - semibold: string; - bold: string; - extrabold: string; - modalHeading: string; - tokenSelect: string; - primaryButton: string; - secondaryButton: string; - }; - borderRadius: { - baseContract: { - none: string; - sm: string; - base: string; - md: string; - lg: string; - xl: string; - "2xl": string; - "3xl": string; - full: string; - half: string; - widgetBorderRadius: string; - primaryButton: string; - secondaryButton: string; - smallButton: string; - }; - connectKit: { - actionButton: string; - connectButton: string; - menuButton: string; - modal: string; - modalMobile: string; - }; - }; - space: { - full: string; - unset: string; - auto: string; - "0": string; - "1": string; - "2": string; - "3": string; - "4": string; - "5": string; - "6": string; - "7": string; - "8": string; - "9": string; - "10": string; - "12": string; - "14": string; - "16": string; - "20": string; - "24": string; - "28": string; - "32": string; - "36": string; - "40": string; - "44": string; - "48": string; - px: string; - buttonMinHeight: string; - }; - heading: { - h1: { - mobile: { - fontSize: string; - }; - tablet: { - fontSize: string; - }; - }; - h2: { - mobile: { - fontSize: string; - }; - tablet: { - fontSize: string; - }; - }; - h3: { - mobile: { - fontSize: string; - }; - tablet: { - fontSize: string; - }; - }; - h4: { - mobile: { - fontSize: string; - }; - tablet: { - fontSize: string; - }; - }; - }; - text: { - small: { - mobile: { - fontSize: string; - }; - tablet: { - fontSize: string; - }; - }; - large: { - mobile: { - fontSize: string; - }; - tablet: { - fontSize: string; - }; - }; - medium: { - mobile: { - fontSize: string; - }; - tablet: { - fontSize: string; - }; - }; - }; - zIndices: { - hide: string; - auto: string; - simple: string; - base: string; - docked: string; - dropdown: string; - sticky: string; - banner: string; - overlay: string; - modal: string; - skipLink: string; - }; - font: { - body: string; - }; -} +import type { SKAppProps } from "@stakekit/widget"; +import type { BundledSKWidgetProps } from "@stakekit/widget/bundle"; ``` -### Custom provider - -Optionally, you can pass externalProviders property to the widget which will be used to connect to the wallet. +Common options include: -```ts - type SKExternalProviders = { - currentChain?: SupportedSKChainIds; - currentAddress: string; - initToken?: `${TokenDto["network"]}-${TokenDto["address"]}`; - supportedChainIds?: SupportedSKChainIds[]; - type: "generic"; - provider: SKWallet; -}; - -type SupportedSKChainIds = - | EvmChainIds - | SubstrateChainIds - | MiscChainIds; - -enum EvmChainIds { - Ethereum = 1, - Polygon = 137, - Optimism = 10, - Arbitrum = 42_161, - AvalancheC = 43_114, - Celo = 42_220, - Harmony = 1_666_600_000, - Viction = 88, - Binance = 56, - Base = 8453, - Linea = 59_144, - Core = 1116, - Sonic = 146, - EthereumHolesky = 17000, - EthereumGoerli = 5, -} - -enum SubstrateChainIds { - Polkadot = 9999, -} - -enum MiscChainIds { - Near = 397, - Tezos = 1729, - Solana = 501, - Tron = 79, - Ton = 3412, -} - -type EVMTx = { - type: "evm"; - tx: DecodedEVMTransaction; -}; - -type SolanaTx = { - type: "solana"; - tx: DecodedSolanaTransaction; -}; - -type TonTx = { - type: "ton"; - tx: DecodedTonTransaction; -}; - -type TronTx = { - type: "tron"; - tx: DecodedTronTransaction; -}; - -type SKTx = EVMTx | SolanaTx | TonTx | TronTx; - -type ActionMeta = { - actionId: ActionDto["id"]; - actionType: ActionDto["type"]; - amount: ActionDto["amount"]; - inputToken: ActionDto["inputToken"]; - providersDetails: { - name: string; - address: string | undefined; - rewardRate: number | undefined; - rewardType: RewardTypes; - website: string | undefined; - logo: string | undefined; - }[]; -}; +- API endpoints and the required `apiKey` +- `lightTheme`, `darkTheme`, or a custom `SKTheme` +- classic or dashboard layout configuration +- initial chain and yield preferences +- translations, icon mappings, and declarative wallet filtering, grouping, and + ordering through `walletPolicy` +- external wallet providers and borrowing support +- tracking callbacks -type SKTxMeta = ActionMeta & { - txId: TransactionDto["id"]; - txType: TransactionDto["type"]; -}; +Configuration passed after mount is normalized and becomes live where the +running widget can safely consume it. Wallet topology—connector mode, provider +presence, connector construction, and related wallet setup—is fixed during +bootstrap; remount the widget to change it. -type SKWallet = { - signMessage: (message: string) => Promise; - switchChain: (chainId: number) => Promise; - getTransactionReceipt?(txHash: string): Promise<{ transactionHash?: string }>; - sendTransaction( - tx: SKTx, - txMeta: SKTxMeta - ): Promise< - | string - | { type: "success"; txHash: string } - | { type: "error"; error: string } - >; -}; -``` +The package exports the supported chain constants, dashboard yield categories, +wallet types, Wallet Policy, transaction metadata types, and themes. Prefer +those exports over copying their shapes into host code. -### Tracking +## Styling -Widget component provides `tracking` prop for analytics to track user actions and page views +Import `@stakekit/widget/style.css` once. Start with `lightTheme` or `darkTheme` +and pass a partial custom theme when necessary: ```tsx -import "@stakekit/widget/package/css"; -import { SKApp, darkTheme } from "@stakekit/widget"; +import { darkTheme, type SKTheme, SKApp } from "@stakekit/widget"; -const App = () => { - return ( - { - console.log(event, props); - }, - trackPageView: (event, props) => { - console.log(event, props); - }, - }} - /> - ); +const theme: SKTheme = { + ...darkTheme, + color: { + ...darkTheme.color, + primaryButtonBackground: "#6d5dfc", + }, }; -``` - -## React Native wallets usage -To use Widget with your wallets managed provider, you can use utility hook to get prepared props and pass them to `WebView` component from `react-native-webview`. Using widget with injected provider skips connection step. - -First, install package: - -```bash -npm install @stakekit/use-inject-provider +; ``` -or +Use the exported `SKTheme` type as the current contract rather than maintaining +a handwritten list of theme tokens. -```bash -yarn add @stakekit/use-inject-provider -``` +## Development -or +This repository uses the pnpm version pinned by mise: -```bash -pnpm add @stakekit/use-inject-provider +```sh +mise exec -- pnpm install +mise exec -- pnpm dev ``` -After that, pass wallets managed EIP-1193 provider and web-views ref to `useInjectProvider`, and you'll receive `injectedJavaScript` and `onMessage` props that you need to pass to `WebView` component. - -Example: - -```tsx -import React, { useRef } from "react"; -import { StyleSheet } from "react-native"; -import WebView from "react-native-webview"; -import { useInjectProvider } from "@stakekit/use-inject-provider"; - -// Some EIP1193Provider thats managed by wallet -class Provider { - async request(args) { - switch (args.method) { - case "eth_accounts": - case "eth_requestAccounts": { - // get accounts from your wallet - // ... - return ["0xe455036e2f3a26df7014b7dbf6cedbbf81433478"]; - } - - case "eth_chainId": { - // get current chain id - return "1"; - } - - case "eth_sendTransaction": { - // send transaction and return transaction hash - // ... - return "some_transaction_hash"; - } - - default: - throw new Error("unhandled method"); - } - } -} - -const provider = new Provider(); - -export const WebViewStake = () => { - const webViewRef = useRef(null); - - const { injectedJavaScript, onMessage } = useInjectProvider({ - webViewRef, - provider, - }); - - return ( - - ); -}; - -const styles = StyleSheet.create({ - container: { flex: 1 }, -}); -``` +Useful checks are `mise exec -- pnpm lint`, `mise exec -- pnpm test`, and +`mise exec -- pnpm check`. Contributor workflow and codebase conventions live +in [`AGENTS.md`](AGENTS.md). diff --git a/agent-patterns/effect-atoms.md b/agent-patterns/effect-atoms.md deleted file mode 100644 index e0b801e4b..000000000 --- a/agent-patterns/effect-atoms.md +++ /dev/null @@ -1,306 +0,0 @@ -# Effect Atom Patterns - -Use this when writing or changing code that uses Effect's unstable atom -reactivity APIs. The source of truth reviewed for these patterns is the vendored -Effect repo: `@repos/effect/LLMS.md`, -`@repos/effect/packages/effect/src/unstable/reactivity/Atom.ts`, -`AtomRegistry.ts`, `AsyncResult.ts`, `Reactivity.ts`, `Hydration.ts`, -`AtomHttpApi.ts`, `AtomRpc.ts`, and the tests in -`@repos/effect/packages/effect/test/reactivity`. - -## Imports - -Import atom APIs from the unstable reactivity barrel. - -```ts -import { Effect, Layer, Schema, Stream } from "effect" -import { AsyncResult, Atom, AtomRegistry, Hydration } from "effect/unstable/reactivity" -``` - -## Registry Owns State - -An `Atom` is a value description. An `AtomRegistry` owns cached values, -dependency edges, subscriptions, running fibers, stream scopes, and disposal. -Use one registry per isolated lifetime: UI root, request, route boundary, or -test. - -```ts -const count = Atom.make(0) -const doubled = Atom.make((get) => get(count) * 2) - -const registry = AtomRegistry.make() -registry.set(count, 21) -registry.get(doubled) // 42 -``` - -The same atom object can have different values in different registries. After -`registry.dispose()`, later atom access is an error. - -## Keep Atom Identity Stable - -Do not create parameterized atoms inline during reads or renders. Atom identity -is the cache key unless the atom is serializable. Use `Atom.family` for -parameterized atoms so the same input returns the same atom object. - -```ts -const userAtom = Atom.family((id: string) => - UserClient.runtime.atom(UserClient.use((client) => client.getUser({ id }))).pipe( - Atom.withLabel(`user:${id}`) - ) -) -``` - -Use `Atom.withLabel` on important atoms. It only adds diagnostic metadata and -does not change behavior. - -## Choose The Right Constructor - -Use `Atom.make(value)` for writable local state, `Atom.make((get) => value)` for -derived synchronous state, and `Atom.make(effectOrStream)` for read atoms that -produce `AsyncResult`. - -```ts -const search = Atom.make("") - -const trimmedSearch = Atom.make((get) => get(search).trim()) - -const users = Atom.make((get) => - Effect.gen(function*() { - const query = get(trimmedSearch) - return yield* UserApi.use((api) => api.search(query)) - }) -) -``` - -Use `Atom.fn` for command-style effects that run when written to. Use -`Atom.fnSync` for synchronous commands. Before the first write, `Atom.fn` -returns `AsyncResult.initial()` unless `initialValue` is supplied, and -`Atom.fnSync` returns `Option.none()` unless `initialValue` is supplied. - -```ts -const saveUser = Atom.fn<{ readonly id: string; readonly name: string }>()( - Effect.fn("saveUser")(function*(input) { - return yield* UserApi.use((api) => api.save(input)) - }) -) - -registry.set(saveUser, { id: "1", name: "Ada" }) -``` - -Write `Atom.Reset` to clear an `Atom.fn` result back to its initial state and -`Atom.Interrupt` to interrupt the current computation. Set `{ concurrent: true }` -only when multiple writes should run at the same time; the default interrupts -and replaces the previous run. - -## Read Dependencies Deliberately - -Inside an atom read, `get(atom)` records a dependency. When that dependency -changes, the current atom is invalidated. Use `get.once(atom)` when you need the -current value without creating a dependency edge. - -Use `get.result(asyncAtom)` to await an `AsyncResult` atom from another effect -atom. It waits while the result is `Initial`; pass `{ suspendOnWaiting: true }` -when stale values marked `waiting` should also suspend. - -```ts -const enrichedUser = Atom.make((get) => - Effect.gen(function*() { - const user = yield* get.result(userAtom("1"), { suspendOnWaiting: true }) - return { ...user, displayName: user.name.toUpperCase() } - }) -) -``` - -In `Atom.fn` bodies, `get.result` behaves as a one-shot wait instead of a normal -dependency read. If the command should rerun from another atom changing, read -that atom with `get(atom)` as part of the command trigger or use reactivity keys. - -## Handle AsyncResult As State - -`AsyncResult` has three variants: `Initial`, `Success`, and `Failure`. The -`waiting` flag is an overlay, not a fourth variant. A waiting success or failure -can still contain the previous usable value. - -Prefer `AsyncResult.matchWithWaiting`, `AsyncResult.builder`, or explicit -refinements instead of assuming any non-success is a loading state. - -```ts -const view = AsyncResult.matchWithWaiting(result, { - onWaiting: () => "Loading", - onSuccess: ({ value }) => value.name, - onError: (error) => `Error: ${String(error)}`, - onDefect: () => "Unexpected error" -}) -``` - -`AsyncResult.value(result)` and `AsyncResult.getOrElse(result, fallback)` may -return a previous success stored inside a failure. Inspect -`AsyncResult.cause(result)` or `AsyncResult.error(result)` when current failure -versus stale data matters. - -## Manage Lifetime Explicitly - -Unobserved atoms are auto-disposed by default. That means local state can reset, -effects can restart, streams can resubscribe, and finalizers can run after the -last listener or dependent child disappears. - -Use the narrowest lifetime tool that matches the behavior: - -- `Atom.keepAlive` keeps an atom cached even when unobserved. -- `Atom.setIdleTTL(duration)` keeps an unused atom around for a finite idle time. -- `Atom.autoDispose` restores default disposal on a copied atom. -- `registry.mount(atom)` keeps an atom alive until the returned release function - is called. -- `Atom.mount(atom)` keeps an atom alive for the current Effect scope. - -Always release `registry.subscribe` and `registry.mount` callbacks when -integrating with external callback-based code. - -## Batch Related Writes - -Use `Atom.batch` when multiple synchronous writes should invalidate dependents -and notify listeners once after the final state is known. - -```ts -Atom.batch(() => { - registry.set(firstName, "Ada") - registry.set(lastName, "Lovelace") -}) -``` - -Reads inside a batch can still rebuild from the latest written state, but -listeners are notified after the batch commits. - -## Use Runtime Atoms For Services - -Use `Atom.runtime(layer)` or `Atom.context({ memoMap })` when atom effects need -Effect services. The runtime builds the layer with a memo map, provides -`AtomRegistry`, `Scope`, `Scheduler`, and `Reactivity`, and exposes -`runtime.atom`, `runtime.fn`, `runtime.pull`, and `runtime.subscriptionRef`. - -```ts -const UserRuntime = Atom.runtime(UserApi.layer) - -const user = Atom.family((id: string) => - UserRuntime.atom(UserApi.use((api) => api.getUser(id)), { - initialValue: { id, name: "Loading" } - }) -) - -const saveUser = UserRuntime.fn( - Effect.fn("saveUser")(function*(input: User) { - return yield* UserApi.use((api) => api.saveUser(input)) - }), - { reactivityKeys: { users: [] } } -) -``` - -Use registry `initialValues` with `Atom.initialValue(runtime.layer, testLayer)` -to replace runtime services in tests. - -## Invalidate Server State With Reactivity Keys - -Use `Atom.withReactivity(keys)` for reads that should refresh after matching -invalidations. Use `runtime.fn(..., { reactivityKeys })`, -`Reactivity.mutation(effect, keys)`, or `Reactivity.invalidate(keys)` for writes -that should trigger those refreshes after success. - -Keys can be a flat array or a record. Prefer stable primitive keys or stable ids; -non-primitive keys are matched by `Hash.hash`. - -```ts -const user = UserRuntime.atom(UserApi.use((api) => api.getUser("1"))).pipe( - UserRuntime.factory.withReactivity({ users: ["1"] }) -) - -const saveUser = UserRuntime.fn( - (input: User) => UserApi.use((api) => api.saveUser(input)), - { reactivityKeys: { users: ["1"] } } -) -``` - -## Streams, Pulls, And SubscriptionRefs - -An `Atom.make(stream)` stores the latest emitted item in an `AsyncResult`. An -empty stream completes as `NoSuchElementError`. Failures preserve the latest -previous success when possible. - -Use `Atom.pull(stream)` or `runtime.pull(stream)` for paginated or incremental -streams that should advance only when the atom is written to. It accumulates -items by default; pass `{ disableAccumulation: true }` when each pull should -replace the previous batch. - -Use `Atom.subscriptionRef(refOrEffect)` when state already lives in a -`SubscriptionRef`; writes to the atom update the ref. - -## Persistence And Hydration - -Mark atoms that cross registry boundaries with `Atom.serializable({ key, -schema })`. Only serializable atoms are included in `Hydration.dehydrate`, and -`Hydration.hydrate` must run before the matching atoms are first read. - -```ts -const user = userAtom("1").pipe( - Atom.serializable({ - key: "user:1", - schema: AsyncResult.Schema({ - success: User, - error: UserError - }) - }) -) - -const state = Hydration.dehydrate(serverRegistry) -Hydration.hydrate(clientRegistry, state) -``` - -Stable keys matter more than atom identity during hydration. The target atom must -use a compatible schema. `Hydration.dehydrate(..., { encodeInitialAs: "promise" })` -uses a live JavaScript promise for initial async results, so do not serialize -that form across JSON or processes. - -For browser URL state, `Atom.searchParam` requires synchronous schemas with no -Effect context. For storage-backed state, use `Atom.kvs` with an atom runtime -that provides `KeyValueStore`. - -## Remote API Helpers - -Use `AtomHttpApi.Service` or `AtomRpc.Service` when typed HTTP API or RPC -clients should participate in atom caching, invalidation, and hydration. - -- Queries return `Atom>` for non-streaming endpoints. -- Mutations return `AtomResultFn`. -- `reactivityKeys` connect successful mutations to query refreshes. -- `timeToLive` maps to `Atom.setIdleTTL` for finite durations and - `Atom.keepAlive` for infinite durations. -- `serializationKey` is required for serializable queries, and should uniquely - identify the endpoint plus request. -- RPC streaming queries return pull atoms and are not serializable query atoms. - -## Testing Patterns - -Use `it.effect` for Effect-based tests, `AtomRegistry.make()` for an isolated -cache, fake timers or `TestClock` for delayed atoms, and explicit -`registry.mount(atom)` when async work must stay alive during the test. - -```ts -it.effect("refreshes after mutation", () => - Effect.gen(function*() { - const registry = AtomRegistry.make() - const unmount = registry.mount(user) - - registry.set(saveUser, { id: "1", name: "Grace" }) - yield* Effect.yieldNow - - const result = registry.get(user) - assert(AsyncResult.isSuccess(result)) - assert.strictEqual(result.value.name, "Grace") - - unmount() - })) -``` - -Prefer `yield* Effect.yieldNow`, fake timer advancement, or `TestClock` over -real sleeps. Assert `AsyncResult` variants and `waiting` flags directly. For -lifetime behavior, assert node disposal by reading again after yielding, or use -`keepAlive` / `mount` when state should persist. diff --git a/agent-patterns/effect-http-client.md b/agent-patterns/effect-http-client.md deleted file mode 100644 index 7d6ad9168..000000000 --- a/agent-patterns/effect-http-client.md +++ /dev/null @@ -1,310 +0,0 @@ -# Effect HttpClient Patterns - -Use this when writing project code that talks to external HTTP APIs with Effect. -The source of truth reviewed for these patterns is the vendored Effect repo: -`repos/effect/LLMS.md`, `repos/effect/ai-docs/src/50_http-client/10_basics.ts`, -and the `repos/effect/packages/effect/src/unstable/http/HttpClient*.ts` modules -plus their tests. - -## Imports - -Prefer the unstable HTTP barrel unless nearby code imports individual modules. - -```ts -import { Context, Effect, flow, Layer, Schedule, Schema, Stream } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { RateLimiter } from "effect/unstable/persistence" -``` - -Platform implementations are provided as layers. Use `FetchHttpClient.layer` for -portable fetch-based code unless the runtime has a more specific layer nearby -(`NodeHttpClient.layerFetch`, `NodeHttpClient.layerNodeHttp`, -`NodeHttpClient.layerUndici`, `BrowserHttpClient.layerXMLHttpRequest`, etc.). - -## Build API Clients As Services - -Wrap API-specific HTTP behavior in a `Context.Service` layer. Acquire -`HttpClient.HttpClient` once, then apply shared middleware such as base URL, -headers, status filtering, retries, tracing spans, and error mapping. - -```ts -class Todo extends Schema.Class("Todo")({ - id: Schema.Number, - title: Schema.String, - completed: Schema.Boolean -}) {} - -export class TodoApi extends Context.Service Effect.Effect -}>()("app/TodoApi") { - static readonly layer = Layer.effect( - TodoApi, - Effect.gen(function*() { - const client = (yield* HttpClient.HttpClient).pipe( - HttpClient.mapRequest(flow( - HttpClientRequest.prependUrl("https://example.com/api"), - HttpClientRequest.acceptJson - )), - HttpClient.filterStatusOk, - HttpClient.retryTransient({ - schedule: Schedule.exponential(100), - times: 3 - }) - ) - - const getTodo = Effect.fn("TodoApi.getTodo")(function*(id: number) { - yield* Effect.annotateCurrentSpan({ id }) - - return yield* client.get(`/todos/${id}`).pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)), - Effect.mapError((cause) => new TodoApiError({ cause })), - Effect.withSpan("TodoApi.getTodo") - ) - }) - - return TodoApi.of({ getTodo }) - }) - ).pipe( - Layer.provide(FetchHttpClient.layer) - ) -} - -export class TodoApiError extends Schema.TaggedErrorClass()("TodoApiError", { - cause: Schema.Defect -}) {} -``` - -## Requests Are Immutable Values - -Use `HttpClientRequest` constructors and combinators instead of hand-building -fetch options. Each combinator returns a new request. - -```ts -const request = HttpClientRequest.post("/todos").pipe( - HttpClientRequest.setUrlParams({ format: "json" }), - HttpClientRequest.bearerToken(token), - HttpClientRequest.acceptJson, - HttpClientRequest.bodyJsonUnsafe(payload) -) - -const response = yield* client.execute(request) -``` - -Prefer schema-backed encoders when a schema exists. `schemaBodyJson` and -`bodyJson` fail in the Effect error channel; `bodyJsonUnsafe` may throw during -JSON encoding and is best reserved for generated code or already-safe payloads. - -```ts -const createTodo = (input: typeof NewTodo.Type) => - HttpClientRequest.post("/todos").pipe( - HttpClientRequest.schemaBodyJson(NewTodo)(input), - Effect.flatMap(client.execute), - Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)) - ) -``` - -Use `setUrlParams` when replacing query values and `appendUrlParams` when -multiple values with the same key must be preserved. Passing a `URL` to a -request constructor extracts search parameters and hash into structured request -fields. - -## Status Codes Are Not Errors By Default - -`HttpClient` succeeds with an `HttpClientResponse` for non-2xx statuses. Choose -one of these explicitly: - -```ts -// Simple API: fail on anything outside 2xx. -const json = yield* client.get("/todos/1").pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)) -) -``` - -```ts -// Typed API: branch by exact status or status class. -const result = yield* client.post("/todos").pipe( - Effect.flatMap(HttpClientResponse.matchStatus({ - 201: HttpClientResponse.schemaBodyJson(Todo), - 400: (response) => - Effect.flatMap( - HttpClientResponse.schemaBodyJson(ApiProblem)(response), - (problem) => Effect.fail(new BadRequest({ problem })) - ), - "5xx": (response) => Effect.fail(new RemoteServiceUnavailable({ status: response.status })), - orElse: (response) => Effect.fail(new UnexpectedStatus({ status: response.status })) - })) -) -``` - -Use `HttpClient.filterStatusOk` on the client when every request made by that -client expects 2xx responses. Use `HttpClientResponse.matchStatus` when the API -has typed non-2xx responses. - -## Decode Responses Deliberately - -Body readers are effects and can fail with `HttpClientError`: - -```ts -yield* response.text -yield* response.json -yield* response.arrayBuffer -yield* response.urlParamsBody -``` - -Prefer schema decoders for JSON and headers: - -```ts -yield* HttpClientResponse.schemaBodyJson(Todo)(response) -yield* HttpClientResponse.schemaJson(ResponseEnvelope)(response) -yield* HttpClientResponse.schemaNoBody(HeaderOnlyResponse)(response) -``` - -Use `schemaBodyJson` for body-only JSON. Use `schemaJson` when the schema covers -the whole `{ status, headers, body }` response shape. `response.json` parses an -empty text body as `null`. `response.stream` fails if there is no body. - -## Error Handling - -The public failure is usually `HttpClientError.HttpClientError`. Inspect -`error.reason._tag` for the precise cause: - -- `TransportError`, `EncodeError`, and `InvalidUrlError` happen before a - response exists. -- `StatusCodeError`, `DecodeError`, and `EmptyBodyError` include the response. - -Do not try to catch `StatusCodeError` directly with `Effect.catchTag` on a normal -client call; the outer tag is `HttpClientError`. Catch `HttpClientError` and -branch on `reason._tag`, or map errors at the API service boundary. - -```ts -const recovered = client.get("/todos/1").pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.catchTag("HttpClientError", (error) => { - if (error.reason._tag === "StatusCodeError" && error.response?.status === 404) { - return Effect.succeedNone - } - return Effect.fail(error) - }) -) -``` - -## Middleware Ordering - -`HttpClient` middleware transforms a client and is usually read left to right in -the `pipe`. - -```ts -const client = baseClient.pipe( - HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.example.com")), - HttpClient.mapRequest(HttpClientRequest.bearerToken(token)), - HttpClient.filterStatusOk, - HttpClient.retryTransient({ times: 3 }) -) -``` - -Use `mapRequest` for transformations that should run after existing request -preprocessing. Use `mapRequestInput` only when a transformation must run before -previously installed request middleware. - -Useful middleware: - -- `mapRequest` / `mapRequestEffect` for base URLs, headers, auth, and request - normalization. -- `filterStatus` / `filterStatusOk` for turning unacceptable statuses into - failures. -- `retryTransient` for transport failures, timeouts, and transient statuses - (`408`, `429`, `500`, `502`, `503`, `504`). The default `retryOn` is - `"errors-and-responses"`. -- `followRedirects()` for following 3xx `location` headers, defaulting to 10 - redirects. -- `withCookiesRef` for cookie jars across requests. -- `tap`, `tapError`, and `tapRequest` for logging/metrics without changing the - response. - -## Rate Limiting - -Use `HttpClient.withRateLimiter` with the `RateLimiter` service when requests -must share a limit by key. - -```ts -const limited = client.pipe( - HttpClient.withRateLimiter({ - limiter: yield* RateLimiter.RateLimiter, - key: (request) => request.url, - limit: 10, - window: "1 minute" - }) -) -``` - -By default, it inspects common rate-limit headers such as `ratelimit-limit`, -`x-ratelimit-limit`, `ratelimit-remaining`, `retry-after`, and reset headers. -It also retries `429` responses, including `HttpClientError` values wrapping a -429 `StatusCodeError`, by sending the retry through the limiter again. Set -`disableResponseInspection: true` only when response headers should not affect -future limits. - -## Streaming And Scope - -For response bodies, prefer `HttpClientResponse.stream(effect)` or unwrap -`response.stream` and consume it with `Stream` combinators. - -```ts -const text = yield* client.get("/events").pipe( - Effect.map((response) => response.stream), - Stream.unwrap, - Stream.decodeText(), - Stream.mkString -) -``` - -Non-scoped responses are tied to an abort controller so interrupted body reads -and early-ending streams abort the underlying request. If a request lifetime -must be controlled by a surrounding `Scope`, apply `HttpClient.withScope`. - -## Tracing - -HttpClient creates client spans by default and records method, URL, status, and -redacted headers. Use `Effect.withSpan` around domain operations and -`Effect.annotateCurrentSpan` for request-specific attributes. - -Context references can tune tracing: - -- `HttpClient.TracerDisabledWhen` disables spans for matching requests. -- `HttpClient.TracerPropagationEnabled` controls outgoing trace headers. -- `HttpClient.SpanNameGenerator` customizes span names. - -## Testing Patterns - -Use `it.effect`, `assert` / `strictEqual` helpers from `@effect/vitest`, and -`TestClock` for time-dependent behavior. Avoid `Effect.runSync` in tests. - -Test API code with `HttpClient.make` and `HttpClientResponse.fromWeb` rather -than real network calls. - -```ts -it.effect("decodes todo", () => - Effect.gen(function*() { - const client = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(JSON.stringify({ id: 1, title: "Test", completed: false }), { - status: 200, - headers: { "content-type": "application/json" } - }) - ) - ) - ) - - const todo = yield* program.pipe( - Effect.provide(Layer.succeed(HttpClient.HttpClient, client)) - ) - - strictEqual(todo.id, 1) - })) -``` - -For retry and rate-limit tests, count attempts in a `Ref`, fork the request, and -advance `TestClock` instead of waiting in real time. diff --git a/agent-patterns/effect-stream.md b/agent-patterns/effect-stream.md deleted file mode 100644 index a32eaef12..000000000 --- a/agent-patterns/effect-stream.md +++ /dev/null @@ -1,368 +0,0 @@ -# Effect Stream Patterns - -Use this when writing project code that models incremental, pull-based data with -Effect `Stream`. The source of truth reviewed for these patterns is the vendored -Effect repo: `@repos/effect/LLMS.md`, -`@repos/effect/ai-docs/src/02_stream/*`, and -`@repos/effect/packages/effect/src/Stream.ts`. - -## Imports - -Prefer the stable `effect` barrel unless nearby code imports individual modules. - -```ts -import { - Cause, - Effect, - Fiber, - Option, - Queue, - Schedule, - Sink, - Stream, - TestClock -} from "effect" -``` - -For Node.js readable streams, use the platform adapter in Node-only code. - -```ts -import { NodeStream } from "@effect/platform-node" -``` - -## Streams Are Lazy Descriptions - -A `Stream.Stream` describes a sequence that can emit zero or more `A` -values, fail with `E`, and require services from `R`. It does not run until it -is consumed with `Stream.run*`, `Stream.run`, `Stream.toQueue`, -`Stream.toReadableStream*`, or a similar destructor. - -```ts -const stream = Stream.fromEffect(loadConfig) - -const program = stream.pipe( - Stream.map((config) => config.region), - Stream.runCollect -) -``` - -Reusing the same stream value reruns the description for each consumer. If -several consumers must observe one running producer, use `Stream.share`, -`Stream.broadcast`, `Stream.broadcastN`, a `Queue`, or a `PubSub`. - -## Choose The Smallest Constructor - -Use the constructor that matches the source shape. - -- `Stream.empty`, `Stream.succeed`, `Stream.make`, `Stream.fromIterable` for - in-memory values. -- `Stream.fromEffect` for one effectful value. -- `Stream.fromEffectSchedule` for polling an effect over a schedule. -- `Stream.paginate` for cursor or page APIs. -- `Stream.fromAsyncIterable` for existing async iterables. -- `Stream.fromEventListener` for DOM-style event targets. -- `Stream.callback` for callback APIs that need explicit queue control. -- `Stream.fromQueue` and `Stream.fromPubSub` for Effect concurrency primitives. -- `Stream.fromReadableStream` or `NodeStream.fromReadable` for web or Node - readable streams. - -```ts -const jobs = Stream.paginate(0, (page) => - JobApi.use((api) => api.listJobs({ page })).pipe( - Effect.map(({ items, nextPage }) => [ - items, - nextPage === null ? Option.none() : Option.some(nextPage) - ] as const) - ) -) -``` - -Prefer `Stream.paginate` over building a mutable loop with `Stream.callback` for -normal paginated APIs. Prefer `Stream.fromEffectSchedule` over hand-written -sleep loops for polling. - -## Consume With Intent - -Every stream pipeline should end in a clear consumer. - -```ts -const writeEvents = events.pipe( - Stream.runForEach((event) => EventStore.use((store) => store.write(event))) -) -``` - -Use `Stream.runCollect` only for finite streams with bounded output. For large -or infinite streams, prefer `Stream.runForEach`, `Stream.runFold`, -`Stream.runFoldEffect`, `Stream.runDrain`, or `Stream.run(Sink...)`. - -```ts -const firstTen = source.pipe( - Stream.take(10), - Stream.runCollect -) - -const total = source.pipe( - Stream.map((event) => event.value), - Stream.run(Sink.sum) -) -``` - -`Stream.runHead` and `Stream.runLast` return `Option`. `runLast` waits for the -stream to complete, so do not use it on open-ended streams. - -## Transform Per Element Or Per Chunk Deliberately - -Use element operators for ordinary domain logic. - -```ts -const enriched = orders.pipe( - Stream.filter((order) => order.status === "paid"), - Stream.mapEffect(enrichOrder, { concurrency: 4 }) -) -``` - -Use chunk-aware operators only when chunk boundaries matter or performance is -worth the extra complexity: `Stream.mapArray`, `Stream.mapArrayEffect`, -`Stream.runForEachArray`, `Stream.grouped`, and `Stream.groupedWithin`. - -```ts -const batched = events.pipe( - Stream.groupedWithin(100, "1 second"), - Stream.mapEffect((batch) => EventStore.use((store) => store.writeBatch(batch))) -) -``` - -Do not insert `runCollect` in the middle of a pipeline just to batch values. -Batch with stream operators so backpressure and interruption still work. - -## Bound Concurrency And Buffers - -`Stream.mapEffect`, `Stream.flatMap`, `Stream.mergeAll`, and related operators -can run work concurrently. Choose a concrete concurrency limit for I/O and only -use `"unbounded"` when the upstream is already tightly bounded. - -```ts -const results = ids.pipe( - Stream.mapEffect((id) => RemoteApi.use((api) => api.fetch(id)), { - concurrency: 8 - }) -) -``` - -Set `unordered: true` only when output order does not matter. Concurrent -`Stream.mergeAll` emits values as they arrive. - -```ts -const merged = Stream.mergeAll(streams, { - concurrency: 4, - bufferSize: 16 -}) -``` - -When creating queues, pubsubs, callbacks, broadcasts, or shared streams, avoid -unbounded capacity by default. Pick a bounded `capacity` and a strategy: - -- `suspend` applies backpressure. -- `sliding` keeps newer values and drops older buffered values. -- `dropping` keeps older buffered values and drops newer values. - -## Manage Scope And Cleanup - -Streams run resources for the duration of consumption. Use `Stream.scoped` when -the stream requires `Scope`, and use `Stream.ensuring` or -`Effect.acquireRelease` inside constructors to register finalizers. - -```ts -const resourceStream = Stream.scoped( - Stream.fromEffect( - Effect.acquireRelease( - Connection.open, - (connection) => connection.close() - ) - ) -) -``` - -`Stream.broadcast`, `Stream.broadcastN`, `Stream.share`, `Stream.toQueue`, and -`Stream.toPubSub` return scoped effects. Acquire them inside `Effect.scoped` or -inside a layer so the producer and subscribers are finalized. - -```ts -const program = Effect.scoped( - Effect.gen(function*() { - const shared = yield* updates.pipe( - Stream.share({ capacity: 16, replay: 1 }) - ) - - yield* shared.pipe(Stream.take(1), Stream.runCollect) - }) -) -``` - -When bridging to web or async protocols, make sure cancellation closes the -underlying handle. `Stream.fromReadableStream` cancels the reader by default. -`NodeStream.fromReadable` can close Node streams when done. - -## Bridge Callback APIs Safely - -Use `Stream.fromEventListener` for event targets when it fits. It registers and -removes the listener for the stream lifetime. - -```ts -const clicks = Stream.fromEventListener(button, "click", { - passive: true, - bufferSize: 16 -}) -``` - -Use `Stream.callback` for custom callback APIs. Register cleanup with -`Effect.acquireRelease`, and signal completion with the queue API instead of -leaving consumers waiting forever. - -```ts -const messages = Stream.callback((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const unsubscribeMessage = socket.onMessage((message) => { - Queue.offerUnsafe(queue, message) - }) - - const unsubscribeClose = socket.onClose(() => { - Queue.endUnsafe(queue) - }) - - return { unsubscribeClose, unsubscribeMessage } - }), - ({ unsubscribeClose, unsubscribeMessage }) => - Effect.sync(() => { - unsubscribeMessage() - unsubscribeClose() - }) - ), - { bufferSize: 64, strategy: "sliding" } -) -``` - -Prefer effectful `Queue.offer` when producer code is already inside Effect and -can honor backpressure. Use `Queue.offerUnsafe` only from external synchronous -callbacks where an Effect cannot be yielded. - -## Use Queues And PubSubs For Boundaries - -Use `Queue` when one producer coordinates with one or more competing consumers -that pull work. `Stream.toQueue` creates a scoped dequeue and signals completion -with `Cause.Done`; stream failures fail the queue. - -```ts -const program = Effect.scoped( - Effect.gen(function*() { - const queue = yield* source.pipe(Stream.toQueue({ capacity: 32 })) - const next = yield* Queue.take(queue) - return next - }) -) -``` - -Use `PubSub` when each subscriber should receive published events. Use -`Stream.fromPubSub` or `Stream.broadcast` instead of manually copying events -into several queues. - -## Provide Services At The Stream Boundary - -Service requirements flow through stream types the same way they flow through -`Effect`. Provide a layer to the stream or provide a larger program that runs -the stream. - -```ts -const stream = Stream.fromEffect( - UserApi.use((api) => api.listUsers()) -).pipe( - Stream.flatMap(Stream.fromIterable), - Stream.provide(UserApi.layer) -) -``` - -When converting to web APIs outside Effect, use the service-aware variants: -`Stream.toReadableStreamEffect`, `Stream.toReadableStreamWith`, -`Stream.toAsyncIterableEffect`, or `Stream.toAsyncIterableWith`. - -## Handle Errors In The Pipeline - -Use stream error combinators when recovery should continue as a stream: -`Stream.catchTag`, `Stream.catchTags`, `Stream.catchIf`, `Stream.catchCause`, -`Stream.mapError`, and `Stream.retry`. - -```ts -const recovered = source.pipe( - Stream.catchTag("RateLimitError", () => - Stream.fromEffectSchedule( - Effect.succeed({ type: "retrying" as const }), - Schedule.spaced("1 second") - ).pipe(Stream.take(1)) - ) -) -``` - -Use `Effect.catchTag` after a `Stream.run*` call when the whole consumed stream -should fail or recover as one effect. Use `Stream.result` when downstream code -needs successes and the first failure as values; the stream still ends after -that failure. - -## Decode And Encode Streaming Data - -For byte streams, decode text before string operations and split lines with -`Stream.splitLines` so delimiters spanning chunks are handled correctly. - -```ts -const lines = responseBytes.pipe( - Stream.decodeText(), - Stream.splitLines, - Stream.runForEach(processLine) -) -``` - -For NDJSON or Msgpack, use the encoding channels and schema-backed variants -instead of hand-parsing inside `Stream.map`. - -```ts -import { Ndjson } from "effect/unstable/encoding" - -const events = bytes.pipe( - Stream.pipeThroughChannel(Ndjson.decodeSchema(Event)()), - Stream.mapError((cause) => new EventDecodeError({ cause })) -) -``` - -Use `ignoreEmptyLines: true` for NDJSON inputs that may contain blank lines. - -## Testing Patterns - -Use `it.effect` and consume the stream in the test. Bound infinite streams with -`Stream.take`, and use `TestClock` for schedules, debounce, throttle, retries, -or `groupedWithin`. - -```ts -import { strictEqual } from "node:assert" -import { Effect, Fiber, Schedule, Stream, TestClock } from "effect" - -it.effect("polls three times", () => - Effect.gen(function*() { - const fiber = yield* Stream.fromEffectSchedule( - Effect.succeed("tick"), - Schedule.spaced("1 second") - ).pipe( - Stream.take(3), - Stream.runCollect, - Effect.fork - ) - - yield* TestClock.adjust("2 seconds") - - const values = yield* Fiber.join(fiber) - strictEqual(values.length, 3) - })) -``` - -For queue and callback streams, assert finalization as well as emitted values. -Fork consumers before publishing when the source is live, then interrupt or close -the scope to verify cleanup. diff --git a/amplify.yml b/amplify.yml index 8dbd96358..e8da29bc3 100644 --- a/amplify.yml +++ b/amplify.yml @@ -8,9 +8,6 @@ frontend: - export PATH="$HOME/.local/share/mise/shims:$PATH" - mise install - pnpm install --frozen-lockfile - - echo "VITE_YIELDS_API_URL=$VITE_YIELDS_API_URL" >> packages/widget/.env - - echo "VITE_API_URL=$VITE_API_URL" >> packages/widget/.env - - echo "VITE_API_KEY=$VITE_API_KEY" >> packages/widget/.env build: commands: diff --git a/biome.json b/biome.json index 21dc2774d..fb41fc344 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,7 @@ "!**/region-iso-3166-codes.ts", "!**/mockServiceWorker.js", "!**/package.json", - "!**/repos" + "!**/.repos" ] }, "formatter": { @@ -35,7 +35,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "style": { "noNonNullAssertion": "off", "noParameterAssign": "error", @@ -47,7 +47,8 @@ "noUnusedTemplateLiteral": "error", "useNumberNamespace": "error", "noInferrableTypes": "error", - "noUselessElse": "error" + "noUselessElse": "error", + "noNestedTernary": "error" }, "suspicious": { "noArrayIndexKey": "off", @@ -63,6 +64,9 @@ "complexity": { "noForEach": "off", "useIndexOf": "off" + }, + "nursery": { + "useReactCompiler": "error" } }, "domains": { @@ -71,6 +75,88 @@ "test": "recommended" } }, + "overrides": [ + { + "includes": [ + "packages/widget/src/**", + "!packages/widget/src/index.package.ts", + "!packages/widget/src/index.bundle.ts", + "!packages/widget/src/public-api/index.package.ts", + "!packages/widget/src/public-api/index.bundle.ts", + "!packages/widget/src/domain/**/contract.ts", + "!packages/widget/src/features/*/index.ts", + "!packages/widget/src/features/*/composition.ts", + "!packages/widget/src/features/*/views.ts", + "!packages/widget/src/features/*/runtime.ts", + "!packages/widget/src/features/borrow/*/index.ts", + "!packages/widget/src/features/borrow/*/composition.ts", + "!packages/widget/src/features/borrow/*/views.ts", + "!packages/widget/src/features/borrow/*/runtime.ts", + "!packages/widget/src/resources/*/index.ts", + "!packages/widget/src/services/api/operations.ts", + "!packages/widget/src/services/api/resource-sources.ts", + "!packages/widget/src/services/api/runtime.ts" + ], + "linter": { + "rules": { + "performance": { + "noBarrelFile": "error" + }, + "style": { + "useExportType": "error", + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/index.package", "**/index.bundle"], + "message": "Public widget entrypoints are outbound-only; import from the defining module." + } + ] + } + } + } + } + } + }, + { + "includes": [ + "packages/widget/src/domain/**", + "packages/widget/src/resources/**", + "packages/widget/src/services/**" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "react", + "react/**", + "react-dom", + "react-dom/**", + "@effect/atom-react", + "@effect/atom-react/**", + "@tanstack/react-query", + "@tanstack/react-query/**" + ], + "message": "Headless domain, resource, and service modules must not import React integrations." + }, + { + "group": ["**/index.package", "**/index.bundle"], + "message": "Public widget entrypoints are outbound-only; import from the defining module." + } + ] + } + } + } + } + } + } + ], "javascript": { "formatter": { "semicolons": "always", diff --git a/docs/adr/0001-one-widget-instance-per-browser-document.md b/docs/adr/0001-one-widget-instance-per-browser-document.md new file mode 100644 index 000000000..462762ed0 --- /dev/null +++ b/docs/adr/0001-one-widget-instance-per-browser-document.md @@ -0,0 +1,6 @@ +# One Widget Instance per browser document + +The Widget supports at most one concurrently mounted Widget Instance in a +browser document; unmounting and later mounting another instance is supported. +Document-global wallet discovery, translation, and host integrations make safe +concurrent isolation disproportionately complex. diff --git a/docs/adr/0002-effect-and-effect-atom-own-application-logic.md b/docs/adr/0002-effect-and-effect-atom-own-application-logic.md new file mode 100644 index 000000000..cbc0b0625 --- /dev/null +++ b/docs/adr/0002-effect-and-effect-atom-own-application-logic.md @@ -0,0 +1,6 @@ +# Effect and Effect Atom own application logic outside React + +Plain TypeScript owns deterministic rules, Effect services own asynchronous +orchestration and lifetimes, and Effect Atom owns reactive composition and +adapters. React renders published state and dispatches commands, trading familiar +hook orchestration for typed failures, scoped cleanup, and testable seams. diff --git a/docs/adr/0003-authoritative-resources-own-shared-remote-reads.md b/docs/adr/0003-authoritative-resources-own-shared-remote-reads.md new file mode 100644 index 000000000..521cc2923 --- /dev/null +++ b/docs/adr/0003-authoritative-resources-own-shared-remote-reads.md @@ -0,0 +1,6 @@ +# Authoritative Resources own shared remote reads + +Each canonical cacheable remote fact has one named Resource that owns request +identity, caching, freshness, retry, pagination, invalidation, and stale-result +suppression. Features project Resource state instead of creating parallel caches, +preventing divergent truth and transport coupling. diff --git a/docs/adr/0004-widget-configuration-is-live-wallet-topology-is-fixed.md b/docs/adr/0004-widget-configuration-is-live-wallet-topology-is-fixed.md new file mode 100644 index 000000000..55736f602 --- /dev/null +++ b/docs/adr/0004-widget-configuration-is-live-wallet-topology-is-fixed.md @@ -0,0 +1,18 @@ +# Widget Configuration remains live while Wallet Topology is fixed at bootstrap + +Normalized Widget Configuration remains live during an Application Runtime +Generation, while Wallet Bootstrap captures connector mode, provider presence, +and connector-construction policy as fixed Wallet Topology. This supports normal +host updates without rebuilding wallet infrastructure from mixed-era inputs; +topology changes require a remount. + +React composition supplies Host Configuration and other initialization values, +but does not carry Effect Layers. Application-owned Atom runtimes compose their +`layer` Atoms. The private Wallet Connector Source runtime provides the +production adapter by default, while tests may replace that runtime Layer when +the Atom registry is created. The selected Layer remains fixed for the +Application Runtime Generation. + +Runtime Layers share one registry-scoped memo map. Services are shared across +the application runtime graph within one generation, finalized when its +registry is disposed, and reconstructed for a later Widget Instance. diff --git a/docs/adr/0005-effect-datetime-owns-application-time.md b/docs/adr/0005-effect-datetime-owns-application-time.md new file mode 100644 index 000000000..ad495d288 --- /dev/null +++ b/docs/adr/0005-effect-datetime-owns-application-time.md @@ -0,0 +1,5 @@ +# Effect DateTime owns application time + +Application instants use Effect `DateTime`, intervals use `Duration`, and current +time comes from `Clock` or `DateTime.now`. A single time model keeps arithmetic, +serialization, tests, and injected time consistent. diff --git a/docs/adr/0006-financial-values-remain-exact-until-execution.md b/docs/adr/0006-financial-values-remain-exact-until-execution.md new file mode 100644 index 000000000..44c270461 --- /dev/null +++ b/docs/adr/0006-financial-values-remain-exact-until-execution.md @@ -0,0 +1,6 @@ +# Financial values remain exact until execution + +Token quantities and financial calculations remain exact decimal or integer +values through validation, preview, and execution; formatting is a presentation +concern. This prevents rounded display values and binary floating-point error +from changing eligibility or transaction inputs. diff --git a/docs/adr/0007-owned-modules-expose-finite-interfaces.md b/docs/adr/0007-owned-modules-expose-finite-interfaces.md new file mode 100644 index 000000000..4a6832fea --- /dev/null +++ b/docs/adr/0007-owned-modules-expose-finite-interfaces.md @@ -0,0 +1,36 @@ +# Owned Modules expose finite interfaces + +An Owned Module is private by default and exposes only explicit, finite +interfaces required by real consumers. This adds interface-maintenance cost but +keeps dependency direction enforceable and lets implementations change without +creating accidental cross-Module contracts. + +The public-api layer may depend on a Domain Module only through that Module's +`contract.ts` interface. A Domain contract may expose selected stable types and +runtime values required by hosts, while Effect schemas, decoders, and other +implementation details remain private. + +This dependency is one-way: Domain Modules do not depend on `public-api`. +Host-facing concepts that are also used by Domain move to the owning Domain +contract rather than creating a bidirectional layer boundary. + +Domain catalogues remain private unless their complete shape is intentionally a +host contract. Public interfaces instead consume narrow projections from the +Domain contract. + +Published declarations use the normal TypeScript declaration graph rather than +maintaining declaration-only mirrors. A library referenced by that graph is a +package runtime dependency even when consumers encounter it only while resolving +types. React and ReactDOM remain peer dependencies. Approved breaking changes +may remove implementation-shaped host APIs instead of preserving third-party +types under Widget-owned names. + +Dependency-cruiser restricts direct imports from `public-api` to Domain +`contract.ts` files. The declaration build emits the source files reachable +from the public entries with `src` as its root. + +Published declarations expose only dependencies that belong to the host +contract. The package entry may reference `effect` and `react`; the standalone +bundle may reference only `effect`. Packed consumers compile both entries under +Bundler and NodeNext resolution, and an executable declaration-graph check +enforces these dependency allowlists. diff --git a/docs/adr/0008-shared-networks-are-the-backend-intersection.md b/docs/adr/0008-shared-networks-are-the-backend-intersection.md new file mode 100644 index 000000000..f28ed932e --- /dev/null +++ b/docs/adr/0008-shared-networks-are-the-backend-intersection.md @@ -0,0 +1,55 @@ +# Shared Networks are the backend intersection + +A Network used in shared Widget domain data must be declared by both the Legacy +and Yield backends. The domain Network module composes their generated schemas +as an intersection rather than trusting either backend alone or admitting their +union, so a one-sided rollout cannot enter shared domain state before both +required product-data sources support it. The public interface imports the +Network type through the Domain Network contract rather than declaring a +mirror. + +Wallet Bootstrap obtains the current project's enabled network IDs from the +Yield API and narrows them to Wallet Networks. It does not consult or fall back +to the Legacy enabled-networks endpoint. + +One wallet-domain catalogue declares Wallet Network support. Its keys define +Wallet Network, and its values provide each Wallet Network's protocol family, +Wallet Routing ID, and a discriminated Protocol Chain Identity. Every Wallet +Network has a routing ID; Protocol Chain Identity may be explicitly unmodelled +when the Widget does not currently possess a genuine protocol-native identity. +Synthetic routing IDs are not treated as native identities. Protocol family is +intrinsic; adapter ownership and transaction-driver selection are not. `misc` +is an aggregation bucket rather than a protocol family, adapter, or transaction +driver. + +Individual adapters own their full chain and connector configuration and +declare their supported-network coverage independently. Existing EVM chain IDs, +Cosmos `chain_id` values, Substrate genesis hashes, and Widget-assigned routing +IDs move into the catalogue and feed adapter configuration. RPC endpoints, +currency metadata, icons, address configuration, and connector construction +remain adapter-owned. Connector modes such as Ledger Live and External Provider +may route across protocol families. The Widget does not maintain an exhaustive +category mapping for Networks that are not Wallet Networks. + +Existing public chain-ID names and values remain compatibility interfaces and +are exported through the Domain contract as dependency-free `as const` objects +with same-name value-union types. Forward access such as +`EvmChainIds.Ethereum` and exhaustive chain-ID typing remain supported. Numeric +enum reverse lookup is intentionally removed. `MiscChainIds` remains a +compatibility name and does not define the Domain taxonomy. `initialChain` +remains numeric; broadening it to non-numeric Wallet Routing IDs is a separate +product-interface change. + +External Provider transaction support remains an adapter-owned capability set. +The compatibility-preserving migration does not narrow the existing public +`SupportedSKChainIds` properties; doing so is a separate breaking-interface +decision. + +Stellar mainnet is the only Wallet Network in the `stellar` protocol family. +Its Stellar Network Passphrase is its Protocol Chain Identity. Backend schemas +may still enumerate Stellar testnet, but Wallet Bootstrap narrows it out because +the wallet-domain catalogue does not declare it. The Widget-assigned numeric +routing ID remains a private adapter detail rather than an addition to +`MiscChainIds`, `SupportedSKChainIds`, `initialChain`, or External Provider +contracts. Built-in Stellar wallet support does not imply Stellar support for +host-supplied External Providers. diff --git a/docs/adr/0009-effect-schema-owns-value-shaped-boundary-decoding.md b/docs/adr/0009-effect-schema-owns-value-shaped-boundary-decoding.md new file mode 100644 index 000000000..2107d8ddf --- /dev/null +++ b/docs/adr/0009-effect-schema-owns-value-shaped-boundary-decoding.md @@ -0,0 +1,10 @@ +# Effect Schema owns value-shaped boundary decoding + +External values can satisfy partial TypeScript shapes without meeting the +contracts that application state relies on. The Module that owns a trust +boundary decodes value-shaped inputs with Effect Schema before their fields +affect application state or behavior; opaque capabilities cross through +explicit typed adapters, and property probes, casts, or handwritten type +predicates do not promote untrusted values into domain types. This centralizes +normalization and typed failures while allowing the boundary contract to reject, +recover from, or discard invalid input. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 000000000..bfb19478b --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,20 @@ +# Domain documentation + +Read `CONTEXT.md` before naming or changing domain concepts. Read only the ADRs +in `docs/adr/` that constrain the area being changed. + +`CONTEXT.md` is the vocabulary source of truth. Use its terms in code, tests, +issues, and proposals; update an existing definition when the owned concept +changes. + +ADRs are a living set of current, durable decisions—not a timeline. Update a +decision in place when its rationale still matters, or remove it when it no +longer constrains the code. Git history preserves earlier states. + +A new ADR is appropriate only for a hard-to-reverse, surprising decision with a +real tradeoff, and requires human approval. Other behavior belongs in code and +tests. Creating any other durable document or document category also requires +human approval. + +If a proposed change conflicts with a current ADR, surface that conflict before +implementation rather than silently overriding it. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 000000000..4f73d48cf --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,30 @@ +# Local issue tracker + +`.scratch/` contains active local specifications, maps, and implementation +issues. It is working state for an effort, not durable product documentation. +Completed-effort retention and removal are human-owned. + +## Feature efforts + +- Directory: `.scratch//` +- Specification: `.scratch//spec.md` +- Issues: `.scratch//issues/-.md`, numbered from `01` +- Triage: a `Status:` line near the top using `triage-labels.md` +- Discussion: append under `## Comments` + +When a workflow says to publish to the issue tracker, create or update the +appropriate file in that effort directory. When it says to fetch a ticket, read +the referenced local file. + +## Wayfinding efforts + +- Map: `.scratch//map.md` +- Child: `.scratch//issues/-.md` +- Type: `research`, `prototype`, `grilling`, or `task` +- Status: `claimed` or `resolved` +- Dependencies: `Blocked by: NN, NN` + +The frontier is the first numbered open, unblocked, unclaimed child. Claim it by +setting `Status: claimed` before work. Resolve it by appending `## Answer`, +setting `Status: resolved`, and adding a concise result and link to the map's +decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 000000000..2411d9b46 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the status strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role, use the corresponding status string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 000000000..e3a65fa67 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,28 @@ +# Releases + +`@stakekit/widget` publishes to npm from `main` through Release Please. Those +releases use the npm `latest` tag. + +## Publishing authority + +Stable npm publication uses the `production` GitHub environment, which must +require approval from a release maintainer. + +Trusted publishing is bound to `.github/workflows/release.yml`. + +## Stable releases + +A push to `main` runs Release Please. Release Please maintains the release pull +request and creates the package Git tag and GitHub release when that pull +request is merged. + +The `production` environment approval gates the release job. + +The workflow publishes only when all of these conditions hold: + +1. A GitHub release exists for `@stakekit/widget@`. +2. That exact package version is not already present on npm. +3. The tagged source builds successfully. + +Stable releases publish without an alternate dist-tag and therefore update +`latest`. diff --git a/mise.lock b/mise.lock index 51b141044..335cb5bf3 100644 --- a/mise.lock +++ b/mise.lock @@ -1,45 +1,13 @@ -# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html [[tools.node]] -version = "24.15.0" +version = "24.20.0" backend = "core:node" -[tools.node."platforms.linux-arm64"] -checksum = "sha256:73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed" -url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-arm64.tar.gz" - -[tools.node."platforms.linux-arm64-musl"] -checksum = "sha256:31e98aa960a067da91edffd5d93bc46657b5d2a8029612c359f5f2ac0060152a" -url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-arm64-musl.tar.gz" - -[tools.node."platforms.linux-x64"] -checksum = "sha256:44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89" -url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.gz" - -[tools.node."platforms.linux-x64-musl"] -checksum = "sha256:f55af5bd489c5347b113ca6594cae00a54b30ba57ac5875324311bfc6f4762e3" -url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-x64-musl.tar.gz" - -[tools.node."platforms.macos-arm64"] -checksum = "sha256:372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4" -url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-arm64.tar.gz" - -[tools.node."platforms.macos-x64"] -checksum = "sha256:ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b" -url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-x64.tar.gz" - -[tools.node."platforms.windows-x64"] -checksum = "sha256:cc5149eabd53779ce1e7bdc5401643622d0c7e6800ade18928a767e940bb0e62" -url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-win-x64.zip" - -[[tools.npm]] -version = "11.14.1" -backend = "npm:npm" - [[tools."npm:npm"]] -version = "11.14.1" +version = "11.19.1" backend = "npm:npm" [[tools."npm:pnpm"]] -version = "10.33.2" +version = "11.24.0" backend = "npm:pnpm" diff --git a/mise.toml b/mise.toml index 8cc2234b3..db8eb66b3 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] -node = "24.15.0" -"npm:npm" = "11.14.1" -"npm:pnpm" = "10.33.2" \ No newline at end of file +node = "24.20.0" +"npm:npm" = "11.19.1" +"npm:pnpm" = "11.24.0" diff --git a/package.json b/package.json index 9487ca9ea..62c6bc2d0 100644 --- a/package.json +++ b/package.json @@ -6,28 +6,33 @@ ], "author": "Petar Todorovic (https://github.com)", "license": "MIT", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "11.19.0" + } + }, "scripts": { + "dev": "pnpm --filter @stakekit/widget dev", "build": "turbo run build", - "lint": "turbo run lint", + "lint": "turbo run lint //#lint:ast", + "lint:ast": "ast-grep scan --error=no-suppress-all --error=unused-suppression packages/widget --color=never && pnpm run test:ast", + "test:ast": "ast-grep test --skip-snapshot-tests --color=never", "test": "turbo run test", + "test:smoke": "pnpm --filter @stakekit/widget test:smoke", "format": "turbo run format", "clean": "turbo run clean", - "prepare": "husky", - "check-hygiene": "rev-dep config run --list-all-issues", - "check": "turbo run lint && pnpm run check-hygiene && turbo run test && turbo run build" + "prepare": "husky && pnpm --filter @stakekit/widget exec effect-tsgo patch --typescript --no-oxlint --typescript-package @typescript/native", + "check-hygiene": "turbo run check-hygiene --filter=@stakekit/widget", + "check": "turbo run lint //#lint:ast && pnpm run check-hygiene && turbo run test && turbo run build" }, "devDependencies": { + "@ast-grep/cli": "catalog:", "@biomejs/biome": "catalog:", "@commitlint/cli": "catalog:", "@commitlint/config-conventional": "catalog:", + "@effect/tsgo": "catalog:", "husky": "catalog:", - "rev-dep": "catalog:", "turbo": "catalog:" - }, - "pnpm": { - "patchedDependencies": { - "purify-ts": "patches/purify-ts.patch", - "@stakekit/rainbowkit@2.2.11": "patches/@stakekit__rainbowkit@2.2.11.patch" - } } } diff --git a/packages/examples/with-nextjs/README.md b/packages/examples/with-nextjs/README.md index c4033664f..70fa4e7e3 100644 --- a/packages/examples/with-nextjs/README.md +++ b/packages/examples/with-nextjs/README.md @@ -1,36 +1,13 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# StakeKit Widget with Next.js -## Getting Started +Minimal Next.js host used to verify that the package works in a production +server-rendered application. The widget itself is mounted from a client +component. -First, run the development server: +Set `NEXT_PUBLIC_API_KEY` in `.env`, then run from the repository root: -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +```sh +mise exec -- pnpm --filter @stakekit/with-nextjs dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +The integration is in [`src/app/widget/widget.tsx`](src/app/widget/widget.tsx). diff --git a/packages/examples/with-nextjs/package.json b/packages/examples/with-nextjs/package.json index 03dc05032..11578de1d 100644 --- a/packages/examples/with-nextjs/package.json +++ b/packages/examples/with-nextjs/package.json @@ -18,6 +18,6 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "typescript": "catalog:" + "@typescript/native": "catalog:" } } diff --git a/packages/examples/with-vite-bundled/package.json b/packages/examples/with-vite-bundled/package.json index 78a969803..93bd85fbe 100644 --- a/packages/examples/with-vite-bundled/package.json +++ b/packages/examples/with-vite-bundled/package.json @@ -14,7 +14,7 @@ "@stakekit/widget": "workspace:*" }, "devDependencies": { - "typescript": "catalog:", + "@typescript/native": "catalog:", "vite": "catalog:" } } diff --git a/packages/examples/with-vite-bundled/src/typescript.svg b/packages/examples/with-vite-bundled/src/typescript.svg deleted file mode 100644 index 351f16e65..000000000 --- a/packages/examples/with-vite-bundled/src/typescript.svg +++ /dev/null @@ -1,17 +0,0 @@ - diff --git a/packages/examples/with-vite/README.md b/packages/examples/with-vite/README.md index aa80e0b7a..e48f0e337 100644 --- a/packages/examples/with-vite/README.md +++ b/packages/examples/with-vite/README.md @@ -1,30 +1,12 @@ -# React + TypeScript + Vite +# StakeKit Widget with Vite -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Minimal React + Vite host used to develop and smoke-test the package entrypoint. -Currently, two official plugins are available: +Set `VITE_API_KEY` in `.env.development.local`, then run from the repository +root: -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh - - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} +```sh +mise exec -- pnpm --filter @stakekit/with-vite dev ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +The integration is in [`src/widget/index.tsx`](src/widget/index.tsx). diff --git a/packages/examples/with-vite/index.html b/packages/examples/with-vite/index.html index 87eed82ad..0cef98854 100644 --- a/packages/examples/with-vite/index.html +++ b/packages/examples/with-vite/index.html @@ -1,4 +1,4 @@ - + diff --git a/packages/examples/with-vite/package.json b/packages/examples/with-vite/package.json index c947f694e..c1b24fc58 100644 --- a/packages/examples/with-vite/package.json +++ b/packages/examples/with-vite/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@types/react": "catalog:", "@types/react-dom": "catalog:", - "typescript": "catalog:", + "@typescript/native": "catalog:", "vite": "catalog:", "@vitejs/plugin-react": "catalog:" } diff --git a/packages/widget/.env.example b/packages/widget/.env.example index 77baaa97a..81fe7033e 100644 --- a/packages/widget/.env.example +++ b/packages/widget/.env.example @@ -6,5 +6,6 @@ VITE_FORCE_WALLET_CONNECT_ONLY=false VITE_ENABLE_MSW_MOCK=true VITE_SHOW_QUERY_DEVTOOLS=true VITE_FORCE_ADDRESS= +VITE_FORCE_BORROW= VITE_FORCE_DASHBOARD= -VITE_APP_VARIANT= \ No newline at end of file +VITE_APP_VARIANT= diff --git a/packages/widget/ARCHITECTURE.md b/packages/widget/ARCHITECTURE.md new file mode 100644 index 000000000..aa38c3bc4 --- /dev/null +++ b/packages/widget/ARCHITECTURE.md @@ -0,0 +1,177 @@ +# Widget Architecture + +This document records stable ownership and dependency boundaries. Behavior, +route topology, failure presentation, and detailed workflows belong in code and +tests. Executable policy lives in Biome, dependency-cruiser, Knip, and ast-grep. + +## Ownership + +`packages/widget/src` is organized by the owner of a capability: + +```text +app/ application composition, routing, and runtime assembly +features/ user-facing capabilities and their state +resources/ authoritative cacheable remote reads +services/ side effects and external-system adapters +domain/ framework-independent rules and models +shared/ neutral utilities and UI primitives +public-api/ host-facing types +``` + +Code stays private to its owner unless another owner needs a deliberate +interface. Shared is for neutral building blocks, not displaced feature logic. +The application composes Modules; it does not absorb their behavior. + +## Dependency direction + +Dependencies point toward narrow interfaces and stable lower-level concepts: + +```text +outbound package entries + | + v +application composition + | + v +feature interfaces and resource interfaces + | + v +owned implementations -> domain rules / services / shared primitives +``` + +Imports within an owner may target its implementation. Imports across owners +use that owner's declared interface. Generated API clients and transport details +remain private to `services/api`. + +`public-api` may import Domain only through `domain/**/contract.ts`. Domain never +imports `public-api`. A Domain contract may publish selected stable types and +runtime values, while schemas, decoders, catalogues, and other implementation +files stay private. + +`src/index.package.ts` and `src/index.bundle.ts` are outbound-only package +entries. Nothing under `src` imports them. + +## Module interfaces + +An Owned Module publishes only the finite interfaces its actual consumers need, +as recorded in [ADR 0007](../../docs/adr/0007-owned-modules-expose-finite-interfaces.md). +Files not named below are private even when they sit at a Module root. + +A Feature Module may publish: + +- `index.ts` for headless models, commands, Atoms, types, and zero-logic adapter + hooks. +- `composition.ts` for screens, routes, layouts, providers, guards, and mounts. +- `views.ts` for rendered elements intentionally reused outside the Feature. +- `runtime.ts` for Effect services and Layers used in runtime assembly. + +Create only the entries with consumers. Internal code normally lives under +`model/`, `state/`, `react/`, and `ui/`. Orchestration-heavy features separate +Atom-independent services in `state/orchestration/` from reactive adapters in +`state/atoms/`. + +Each immediate child of `resources/` is a Resource Module and publishes +`index.ts`. A nested Module Collection consumes child interfaces and publishes +its own facade instead of re-exporting child internals. + +`services/api` is one owned Module with purpose-specific interfaces: + +- `resource-sources.ts` for Resources. +- `operations.ts` for feature orchestration and transaction internals. +- `runtime.ts` for application runtime assembly. + +It has no `index.ts`. Transport, generated clients, and capability +implementations remain private. + +Interface exports are explicit and named. Wildcard exports and convenience +barrels are not interfaces. Tests may deep-import private machinery when the +test is intentionally about that machinery. + +## Application logic + +Plain TypeScript owns deterministic domain state, transitions, invariants, and +projections. Effect services own asynchronous orchestration, side effects, +concurrency, retry, rollback, and scoped lifetimes. Effect Atom owns reactive +composition and the adapter between services and rendering. React renders +published state and dispatches semantic commands. See +[ADR 0002](../../docs/adr/0002-effect-and-effect-atom-own-application-logic.md). + +An orchestration-heavy Feature exposes one feature-owned Effect service. The +application or wallet runtime composes it once and resolves static dependencies +during Layer construction. Runtime operations do not accept Atoms, registries, +or Atom contexts. + +Command Atoms are deliberately shallow. A command may read a snapshot, +normalize it through a pure function, and perform one state transition, scoped +handle operation, or tail delegation. Multi-step coordination belongs in the +owning Effect service. + +## Resources and state + +Each canonical remote fact has one authoritative Resource. The Resource owns +request identity, caching, freshness, pagination, retry, invalidation, loading, +typed failure, and stale-result suppression. Features consume and project that +state rather than creating parallel caches. See +[ADR 0003](../../docs/adr/0003-authoritative-resources-own-shared-remote-reads.md). + +Each workflow fact has one authoritative writer. Atom may project Effect-owned +state but does not maintain a second writable copy or invent a lifecycle state +before its scoped source exists. Local synchronous presentation state such as +focus, hover, disclosure, and DOM refs may remain in React. + +Resource and workflow retention is an explicit ownership decision. Use the +shared application-runtime lifecycle adapter for scoped handles rather than +rebuilding mount graphs in Features. + +## Trust boundaries + +The Module that owns a value-shaped input boundary owns its Effect Schema and +decoder. Unknown values, serialized data, persisted data, URL data, and callback +results are decoded before their fields affect application state or behavior. +The boundary contract decides whether to reject, recover from, or discard an +invalid input. + +Opaque capabilities such as functions, DOM nodes, and SDK instances cross +through explicit typed adapters rather than shape inspection. Property probes, +casts, and handwritten type predicates do not promote untrusted values into +domain types. Ordinary narrowing of an already-decoded or internal +discriminated union remains allowed. See +[ADR 0009](../../docs/adr/0009-effect-schema-owns-value-shaped-boundary-decoding.md). + +## Runtime and lifecycle + +One Application Runtime Generation is created per Widget Instance. The current +Widget Configuration remains live within it, while Wallet Topology is captured +at Wallet Bootstrap and remains fixed. See +[ADR 0004](../../docs/adr/0004-widget-configuration-is-live-wallet-topology-is-fixed.md). + +React composition seeds application initialization values, not Effect Layers. +Application-owned Atom runtimes compose through their `layer` Atoms. Production +runtimes install their default adapters, and tests replace the narrowest runtime +Layer relevant to the behavior through Atom registry initial values. Runtime +composition preserves registry-scoped Layer memoization; do not reconstruct +Layers from built Effect contexts or use `Layer.fresh` without an explicit need +for separate service instances. + +At most one Widget Instance may be mounted per browser document. Unmounting and +later mounting a new instance creates a fresh generation. See +[ADR 0001](../../docs/adr/0001-one-widget-instance-per-browser-document.md). + +React Context is limited to real render-tree concerns and third-party provider +boundaries. Application services and cross-feature state use the Effect runtime +and Effect Atom registry. + +## Enforcement + +- dependency-cruiser enforces cross-Module direction and keeps API internals + private. +- Biome constrains barrels and keeps React out of domain, Resource, and service + logic. +- ast-grep rejects ad hoc Atom runtimes, direct Effect runtime execution, native + `Date`, global `fetch`, throws in Effect generators, and wildcard Module + exports. +- Knip checks ordinary and production reachability. + +Run `mise exec -- pnpm check-hygiene` after changing the module graph. Change +the implementation or the exact policy when the design changes; do not silence +the checks broadly. diff --git a/packages/widget/knip.jsonc b/packages/widget/knip.jsonc new file mode 100644 index 000000000..dde24b43e --- /dev/null +++ b/packages/widget/knip.jsonc @@ -0,0 +1,29 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema-jsonc.json", + "entry": [ + "src/index.package.ts!", + "src/index.bundle.ts!", + "src/public-api/index.package.ts!", + "src/public-api/index.bundle.ts!", + "vite/*.ts!", + "tests/package-types/*.ts", + "tests/utils/setup.browser.ts", + "tests/utils/setup.dom.ts", + "tests/**/*.test.ts", + "tests/**/*.test.tsx" + ], + "project": [ + "src/**/*.{ts,tsx}!", + "scripts/**/*.{ts,mts}", + "tests/**/*.{ts,tsx}", + "vite/**/*.ts!" + ], + "ignoreIssues": { + "src/generated/**": ["exports", "types"] + }, + "ignoreDependencies": [ + "@effect/language-service", + "@effect/openapi-generator", + "@effect/platform-node" + ] +} diff --git a/packages/widget/package.json b/packages/widget/package.json index 1e133d03e..856f63134 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -3,17 +3,17 @@ "version": "0.0.282", "type": "module", "main": "./dist/package/index.package.js", - "types": "./dist/types/index.package.d.ts", + "types": "./dist/types/public-api/index.package.d.ts", "workspaces": [ "packages/*" ], "exports": { ".": { - "types": "./dist/types/index.package.d.ts", + "types": "./dist/types/public-api/index.package.d.ts", "default": "./dist/package/index.package.js" }, "./bundle": { - "types": "./dist/types/index.bundle.d.ts", + "types": "./dist/types/public-api/index.bundle.d.ts", "default": "./dist/bundle/index.bundle.js" }, "./style.css": { @@ -43,18 +43,33 @@ "scripts": { "dev": "vite -c vite/vite.config.dev.ts", "start": "pnpm dev", - "build": "pnpm lint && pnpm clean && pnpm build:package && pnpm build:bundle && pnpm build:website && pnpm build:types", - "lint": "biome check . && tsc", - "test": "vitest -c vite/vite.config.dev.ts --retry 2 run", + "build": "pnpm clean && pnpm build:package && pnpm build:bundle && pnpm build:website && pnpm build:types && pnpm check:package-types", + "lint": "biome check . && tsc && tsx scripts/generated-api/check-generated-schemas.ts", "format": "biome check --write . --unsafe", + "clean": "rm -rf dist", + "preview": "vite -c vite/vite.config.dev.ts preview --outDir dist/website", + "gen:api": "tsx scripts/generated-api/generate-effect-openapi.ts && tsx scripts/generated-api/check-generated-schemas.ts", + "test": "pnpm test:unit && pnpm test:dom && pnpm test:browser", + "test:unit": "vitest -c vite/vitest.config.ts --project unit run", + "test:unit:watch": "vitest -c vite/vitest.config.ts --project unit", + "test:dom": "vitest -c vite/vitest.config.ts --project dom run", + "test:dom:watch": "vitest -c vite/vitest.config.ts --project dom", + "test:browser": "vitest -c vite/vitest.config.ts --project browser run", + "test:changed": "vitest -c vite/vitest.config.ts run --project unit --project dom --changed", + "test:changed:all": "vitest -c vite/vitest.config.ts run --changed", + "test:changed:unit": "vitest -c vite/vitest.config.ts run --project unit --changed", + "test:smoke": "tsx scripts/smoke/check-production-host-consumption.ts --check-key && pnpm run clean && pnpm run build:package && pnpm run build:bundle && pnpm run build:types && tsx scripts/smoke/check-production-host-consumption.ts", "build:website": "vite -c vite/vite.config.website build", "build:package": "vite -c vite/vite.config.package.ts build", "build:package:watch": "vite -w -c vite/vite.config.package.ts build", "build:bundle": "vite -c vite/vite.config.bundle.ts build", "build:types": "tsc --project tsconfig.build.json", - "clean": "rm -rf dist", - "preview": "vite -c vite/vite.config.dev.ts preview --outDir dist/website", - "gen:api": "tsx scripts/generate-effect-openapi.ts" + "check:generated-schemas": "tsx scripts/generated-api/check-generated-schemas.ts", + "check:package-types": "tsx scripts/declarations/check-package-types.ts", + "check-hygiene": "pnpm check-hygiene:architecture && pnpm check-hygiene:unused && pnpm check-hygiene:production", + "check-hygiene:architecture": "tsx scripts/architecture/check-architecture.ts", + "check-hygiene:unused": "knip --include files,dependencies,unlisted,exports,nsExports,types,nsTypes --no-progress --treat-config-hints-as-errors", + "check-hygiene:production": "knip --production --include files,dependencies,unlisted --no-progress --treat-config-hints-as-errors" }, "peerDependencies": { "react": ">=18", @@ -68,6 +83,9 @@ "optional": true } }, + "dependencies": { + "effect": "catalog:" + }, "devDependencies": { "@cosmjs/amino": "catalog:", "@cosmjs/encoding": "catalog:", @@ -75,8 +93,12 @@ "@cosmos-kit/keplr": "catalog:", "@cosmos-kit/leap": "catalog:", "@cosmos-kit/walletconnect": "catalog:", + "@creit-tech/stellar-wallets-kit": "catalog:", + "@effect/atom-react": "catalog:", "@effect/openapi-generator": "catalog:", + "@effect/platform-browser": "catalog:", "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", "@faker-js/faker": "catalog:", "@ledgerhq/wallet-api-client": "catalog:", "@ledgerhq/wallet-api-core": "catalog:", @@ -88,12 +110,12 @@ "@radix-ui/react-dropdown-menu": "catalog:", "@radix-ui/react-tooltip": "catalog:", "@radix-ui/react-visually-hidden": "catalog:", - "@rolldown/plugin-babel": "catalog:", "@safe-global/safe-apps-provider": "catalog:", "@safe-global/safe-apps-sdk": "catalog:", + "@solana-mobile/wallet-adapter-mobile": "catalog:", "@solana/wallet-adapter-base": "catalog:", - "@solana/wallet-adapter-react": "catalog:", "@solana/wallet-adapter-wallets": "catalog:", + "@solana/wallet-standard-wallet-adapter-base": "catalog:", "@solana/web3.js": "catalog:", "@stakekit/rainbowkit": "catalog:", "@tanstack/react-query": "catalog:", @@ -105,10 +127,9 @@ "@tronweb3/tronwallet-adapter-ledger": "catalog:", "@tronweb3/tronwallet-adapter-tronlink": "catalog:", "@tronweb3/tronwallet-adapter-walletconnect": "catalog:", - "@types/lodash.merge": "catalog:", - "@types/lodash.uniqwith": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@typescript/native": "catalog:", "@vanilla-extract/css": "catalog:", "@vanilla-extract/dynamic": "catalog:", "@vanilla-extract/recipes": "catalog:", @@ -116,36 +137,31 @@ "@vanilla-extract/vite-plugin": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/browser-playwright": "catalog:", - "@xstate/react": "catalog:", - "@xstate/store": "catalog:", + "@wallet-standard/app": "catalog:", + "@wallet-standard/base": "catalog:", "autoprefixer": "catalog:", - "babel-plugin-react-compiler": "catalog:", "bignumber.js": "catalog:", "chain-registry": "catalog:", "clsx": "catalog:", "cosmjs-types": "catalog:", - "date-fns": "catalog:", - "effect": "catalog:", - "eventemitter3": "catalog:", + "dependency-cruiser": "catalog:", "i18next": "catalog:", "i18next-browser-languagedetector": "catalog:", - "lodash.merge": "catalog:", - "lodash.uniqwith": "catalog:", + "jsdom": "catalog:", + "knip": "catalog:", "mipd": "catalog:", "mixpanel-browser": "catalog:", "motion": "catalog:", "msw": "catalog:", + "oxc-transform-react": "catalog:", "playwright": "catalog:", "postcss": "catalog:", - "purify-ts": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-i18next": "catalog:", "react-loading-skeleton": "catalog:", "react-router": "catalog:", - "reselect": "catalog:", - "rxjs": "catalog:", - "swagger2openapi": "catalog:", + "recharts": "catalog:", "tsx": "catalog:", "typescript": "catalog:", "unplugin-macros": "catalog:", @@ -155,9 +171,7 @@ "vitest": "catalog:", "vitest-browser-react": "catalog:", "wagmi": "catalog:", - "xstate": "catalog:", - "yaml": "catalog:", - "recharts": "catalog:" + "yaml": "catalog:" }, "msw": { "workerDirectory": [ diff --git a/packages/widget/public/mockServiceWorker.js b/packages/widget/public/mockServiceWorker.js index 57451a301..0c970efc9 100644 --- a/packages/widget/public/mockServiceWorker.js +++ b/packages/widget/public/mockServiceWorker.js @@ -7,114 +7,114 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = "2.12.2"; -const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82"; -const IS_MOCKED_RESPONSE = Symbol("isMockedResponse"); -const activeClientIds = new Set(); +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() -addEventListener("install", () => { - self.skipWaiting(); -}); +addEventListener('install', function () { + self.skipWaiting() +}) -addEventListener("activate", (event) => { - event.waitUntil(self.clients.claim()); -}); +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) -addEventListener("message", async (event) => { - const clientId = Reflect.get(event.source || {}, "id"); +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') if (!clientId || !self.clients) { - return; + return } - const client = await self.clients.get(clientId); + const client = await self.clients.get(clientId) if (!client) { - return; + return } const allClients = await self.clients.matchAll({ - type: "window", - }); + type: 'window', + }) switch (event.data) { - case "KEEPALIVE_REQUEST": { + case 'KEEPALIVE_REQUEST': { sendToClient(client, { - type: "KEEPALIVE_RESPONSE", - }); - break; + type: 'KEEPALIVE_RESPONSE', + }) + break } - case "INTEGRITY_CHECK_REQUEST": { + case 'INTEGRITY_CHECK_REQUEST': { sendToClient(client, { - type: "INTEGRITY_CHECK_RESPONSE", + type: 'INTEGRITY_CHECK_RESPONSE', payload: { packageVersion: PACKAGE_VERSION, checksum: INTEGRITY_CHECKSUM, }, - }); - break; + }) + break } - case "MOCK_ACTIVATE": { - activeClientIds.add(clientId); + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) sendToClient(client, { - type: "MOCKING_ENABLED", + type: 'MOCKING_ENABLED', payload: { client: { id: client.id, frameType: client.frameType, }, }, - }); - break; + }) + break } - case "CLIENT_CLOSED": { - activeClientIds.delete(clientId); + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) const remainingClients = allClients.filter((client) => { - return client.id !== clientId; - }); + return client.id !== clientId + }) // Unregister itself when there are no more clients if (remainingClients.length === 0) { - self.registration.unregister(); + self.registration.unregister() } - break; + break } } -}); +}) -addEventListener("fetch", (event) => { - const requestInterceptedAt = Date.now(); +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() // Bypass navigation requests. - if (event.request.mode === "navigate") { - return; + if (event.request.mode === 'navigate') { + return } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. if ( - event.request.cache === "only-if-cached" && - event.request.mode !== "same-origin" + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' ) { - return; + return } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { - return; + return } - const requestId = crypto.randomUUID(); - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); -}); + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) /** * @param {FetchEvent} event @@ -122,28 +122,38 @@ addEventListener("fetch", (event) => { * @param {number} requestInterceptedAt */ async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event); - const requestCloneForEvents = event.request.clone(); + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() const response = await getResponse( event, client, requestId, requestInterceptedAt, - ); + ) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents); + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') // Clone the response so both the client and the library could consume it. - const responseClone = response.clone(); + const responseClone = isEventStreamResponse ? null : response.clone() sendToClient( client, { - type: "RESPONSE", + type: 'RESPONSE', payload: { isMockedResponse: IS_MOCKED_RESPONSE in response, request: { @@ -151,19 +161,21 @@ async function handleRequest(event, requestId, requestInterceptedAt) { ...serializedRequest, }, response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, }, }, }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ); + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) } - return response; + return response } /** @@ -175,30 +187,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) { * @returns {Promise} */ async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId); + const client = await self.clients.get(event.clientId) if (activeClientIds.has(event.clientId)) { - return client; + return client } - if (client?.frameType === "top-level") { - return client; + if (client?.frameType === 'top-level') { + return client } const allClients = await self.clients.matchAll({ - type: "window", - }); + type: 'window', + }) return allClients .filter((client) => { // Get only those clients that are currently visible. - return client.visibilityState === "visible"; + return client.visibilityState === 'visible' }) .find((client) => { // Find the client ID that's recorded in the // set of clients that have registered the worker. - return activeClientIds.has(client.id); - }); + return activeClientIds.has(client.id) + }) } /** @@ -211,36 +223,36 @@ async function resolveMainClient(event) { async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone(); + const requestClone = event.request.clone() function passthrough() { // Cast the request headers to a new Headers instance // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers); + const headers = new Headers(requestClone.headers) // Remove the "accept" header value that marked this request as passthrough. // This prevents request alteration and also keeps it compliant with the // user-defined CORS policies. - const acceptHeader = headers.get("accept"); + const acceptHeader = headers.get('accept') if (acceptHeader) { - const values = acceptHeader.split(",").map((value) => value.trim()); + const values = acceptHeader.split(',').map((value) => value.trim()) const filteredValues = values.filter( - (value) => value !== "msw/passthrough", - ); + (value) => value !== 'msw/passthrough', + ) if (filteredValues.length > 0) { - headers.set("accept", filteredValues.join(", ")); + headers.set('accept', filteredValues.join(', ')) } else { - headers.delete("accept"); + headers.delete('accept') } } - return fetch(requestClone, { headers }); + return fetch(requestClone, { headers }) } // Bypass mocking when the client is not active. if (!client) { - return passthrough(); + return passthrough() } // Bypass initial page load requests (i.e. static assets). @@ -248,15 +260,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { - return passthrough(); + return passthrough() } // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request); + const serializedRequest = await serializeRequest(event.request) const clientMessage = await sendToClient( client, { - type: "REQUEST", + type: 'REQUEST', payload: { id: requestId, interceptedAt: requestInterceptedAt, @@ -264,19 +276,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { }, }, [serializedRequest.body], - ); + ) switch (clientMessage.type) { - case "MOCK_RESPONSE": { - return respondWithMock(clientMessage.data); + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) } - case "PASSTHROUGH": { - return passthrough(); + case 'PASSTHROUGH': { + return passthrough() } } - return passthrough(); + return passthrough() } /** @@ -287,21 +299,21 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { */ function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { - const channel = new MessageChannel(); + const channel = new MessageChannel() channel.port1.onmessage = (event) => { - if (event.data?.error) { - return reject(event.data.error); + if (event.data && event.data.error) { + return reject(event.data.error) } - resolve(event.data); - }; + resolve(event.data) + } client.postMessage(message, [ channel.port2, ...transferrables.filter(Boolean), - ]); - }); + ]) + }) } /** @@ -314,17 +326,17 @@ function respondWithMock(response) { // instance will have status code set to 0. Since it's not possible to create // a Response instance with status code 0, handle that use-case separately. if (response.status === 0) { - return Response.error(); + return Response.error() } - const mockedResponse = new Response(response.body, response); + const mockedResponse = new Response(response.body, response) Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { value: true, enumerable: true, - }); + }) - return mockedResponse; + return mockedResponse } /** @@ -345,5 +357,5 @@ async function serializeRequest(request) { referrerPolicy: request.referrerPolicy, body: await request.arrayBuffer(), keepalive: request.keepalive, - }; + } } diff --git a/packages/widget/scripts/architecture/check-architecture.ts b/packages/widget/scripts/architecture/check-architecture.ts new file mode 100644 index 000000000..56c4da70d --- /dev/null +++ b/packages/widget/scripts/architecture/check-architecture.ts @@ -0,0 +1,57 @@ +/** + * Architecture check for owned Modules and layer imports. + * + * Run via `pnpm check-hygiene:architecture`. Cruises `src` with the + * dependency-cruiser config, then fails on owned-Module cycles that are not + * listed in the baseline. + */ +import { cruise, format } from "dependency-cruiser"; +import { widgetRoot } from "../paths"; +import architectureConfiguration, { + architecturePolicy, +} from "./dependency-cruiser.config.mts"; +import { moduleCycleBaseline } from "./module-cycle-baseline"; +import { + checkOwnedModuleCycles, + formatModuleCycleCheck, +} from "./module-cycle-policy"; + +const { options: configuredOptions = {}, ...ruleSet } = + architectureConfiguration; +const cruiseOutput = await cruise(["src"], { + ...configuredOptions, + baseDir: widgetRoot, + outputType: undefined, + ruleSet, + validate: true, +}); + +if (typeof cruiseOutput.output === "string") { + throw new TypeError("dependency-cruiser returned text instead of its graph"); +} + +const dependencyReport = await format(cruiseOutput.output, { + outputType: "err-long", +}); + +if (typeof dependencyReport.output === "string") { + process.stdout.write(dependencyReport.output); +} + +const moduleCycleCheck = checkOwnedModuleCycles({ + baseline: moduleCycleBaseline, + cruiseResult: cruiseOutput.output, + policy: architecturePolicy, +}); + +const invalidModuleBaseline = + moduleCycleCheck.staleBaseline.length > 0 || + moduleCycleCheck.unbaselinedEdges.length > 0; + +if (invalidModuleBaseline) { + process.stderr.write(`${formatModuleCycleCheck(moduleCycleCheck)}\n`); +} + +if (dependencyReport.exitCode !== 0 || invalidModuleBaseline) { + process.exitCode = 1; +} diff --git a/packages/widget/scripts/architecture/dependency-cruiser.config.mts b/packages/widget/scripts/architecture/dependency-cruiser.config.mts new file mode 100644 index 000000000..ec764c6ce --- /dev/null +++ b/packages/widget/scripts/architecture/dependency-cruiser.config.mts @@ -0,0 +1,519 @@ +/** + * Layer and owned-Module import rules used by `check-architecture.ts`. + */ +import type { IConfiguration, IForbiddenRuleType } from "dependency-cruiser"; + +const layerNames = [ + "app", + "features", + "resources", + "services", + "domain", + "shared", + "generated", + "public-api", +] as const; +const ambientSourceDirectories = ["types"] as const; +type LayerName = (typeof layerNames)[number]; +type InterfaceName = "index" | "composition" | "views" | "runtime"; +type AudienceName = + | "app-composition" + | "app-runtime" + | "borrow-composition" + | "borrow-runtime"; + +type LayerDeclaration = Readonly<{ + name: LayerName; + mayImport: ReadonlyArray; + additionalTargets?: ReadonlyArray<"app-runtime">; +}>; + +export type ModuleCollectionDeclaration = Readonly<{ + kind: "feature" | "nested-feature" | "resource"; + root: string; + interfaces: ReadonlyArray; + interfaceAudiences?: Partial< + Readonly>> + >; + excludedChildren?: ReadonlyArray; + parent?: string; +}>; + +export type OwnedModuleDeclaration = Readonly<{ + kind: "api"; + root: string; + interfaces: Readonly< + Record<"resource-sources" | "operations" | "runtime", ReadonlyArray> + >; +}>; + +type GeneratedRuntimeDeclaration = Readonly<{ + files: ReadonlyArray; + importers: ReadonlyArray; + root: string; +}>; + +const defineLayer = (declaration: LayerDeclaration) => declaration; +const moduleCollection = (declaration: ModuleCollectionDeclaration) => + declaration; +const ownedModule = (declaration: OwnedModuleDeclaration) => declaration; + +export const architecturePolicy = { + layers: [ + defineLayer({ + name: "app", + mayImport: [ + "app", + "features", + "resources", + "services", + "domain", + "shared", + "public-api", + ], + }), + defineLayer({ + name: "features", + mayImport: [ + "features", + "resources", + "services", + "domain", + "shared", + "public-api", + ], + additionalTargets: ["app-runtime"], + }), + defineLayer({ + name: "resources", + mayImport: ["resources", "services", "domain", "shared", "public-api"], + additionalTargets: ["app-runtime"], + }), + defineLayer({ + name: "services", + mayImport: ["services", "domain", "shared", "generated", "public-api"], + }), + defineLayer({ + name: "domain", + mayImport: ["domain", "generated"], + }), + defineLayer({ + name: "shared", + mayImport: ["shared", "domain", "public-api"], + }), + defineLayer({ name: "generated", mayImport: ["generated"] }), + defineLayer({ + name: "public-api", + mayImport: ["domain", "public-api"], + }), + ], + moduleCollections: [ + moduleCollection({ + kind: "feature", + root: "src/features", + interfaces: ["index", "composition", "views", "runtime"], + interfaceAudiences: { + composition: ["app-composition"], + runtime: ["app-runtime"], + }, + }), + moduleCollection({ + kind: "nested-feature", + root: "src/features/borrow", + interfaces: ["index", "composition", "views", "runtime"], + interfaceAudiences: { + composition: ["borrow-composition"], + runtime: ["borrow-runtime"], + }, + excludedChildren: ["model", "react", "state", "ui"], + parent: "src/features/borrow", + }), + moduleCollection({ + kind: "resource", + root: "src/resources", + interfaces: ["index"], + }), + ], + ownedModules: [ + ownedModule({ + kind: "api", + root: "src/services/api", + interfaces: { + "resource-sources": ["src/resources"], + operations: [ + "src/features/*/state/orchestration", + "src/services/transaction-workflow/internal", + ], + runtime: ["src/app/runtime"], + }, + }), + ], + generatedRuntime: { + root: "src/generated/api", + files: ["borrow-client", "legacy", "yield"], + importers: ["src/services/api"], + } satisfies GeneratedRuntimeDeclaration, +} as const; + +const layerRoot = `^src/(?:${layerNames.join("|")})/`; +const declaredSourceDirectories = [ + ...layerNames, + ...ambientSourceDirectories, +] as const; +const undeclaredLayerPath = `^src/(?!(?:${declaredSourceDirectories.join("|")})(?:/|$))[^/]+/`; +const layerPath = (name: LayerName) => `^src/${name}/`; +const audiencePaths: Readonly>> = { + "app-composition": ["^src/app/(?!runtime/)", "^src/App\\.tsx$"], + "app-runtime": ["^src/app/runtime/"], + "borrow-composition": ["^src/features/borrow/composition\\.ts$"], + "borrow-runtime": ["^src/features/borrow/runtime\\.ts$"], +}; +const ownedModulePath = + "^(?:src/features/[^/]+/|src/resources/[^/]+/|src/services/api/)"; + +const makeLayerRules = (): IForbiddenRuleType[] => + architecturePolicy.layers.map((layer) => ({ + name: `layer-${layer.name}`, + severity: "error", + comment: `${layer.name} dependencies follow the default-deny layer matrix.`, + from: { path: layerPath(layer.name) }, + to: { + path: layerRoot, + pathNot: [ + ...layer.mayImport.map(layerPath), + ...(layer.additionalTargets?.map((target) => + target === "app-runtime" ? "^src/app/runtime/" : target + ) ?? []), + ], + }, + })); + +const makeGeneratedRuntimeRules = (): IForbiddenRuleType[] => { + const declaration = architecturePolicy.generatedRuntime; + return [ + { + name: "generated-runtime-clients-private", + severity: "error", + from: { + pathNot: declaration.importers.map(directoryPattern), + }, + to: { + path: `^${declaration.root}/(?:${declaration.files.join("|")})\\.ts$`, + }, + }, + ]; +}; + +type CompiledCollection = Readonly<{ + kind: ModuleCollectionDeclaration["kind"]; + path: string; + anyPath: string; + sameOwnerPath: string; + interfacePath: string; + interfaceCapturePath: string; + sameOwnerInterfacePath: string; + interfacePaths: Readonly>>; + interfaceImporters: Readonly>>; + parentInterfacePath?: string; +}>; + +const compileCollection = ( + declaration: ModuleCollectionDeclaration +): CompiledCollection => { + const excluded = declaration.excludedChildren?.join("|"); + const child = excluded ? `(?!(?:${excluded})(?:/|$))[^/]+` : "[^/]+"; + const interfacePattern = `(?:${declaration.interfaces.join("|")})`; + const interfacePaths = Object.fromEntries( + declaration.interfaces.map((name) => [ + name, + `^${declaration.root}/${child}/${name}\\.ts$`, + ]) + ); + const interfaceImporters = Object.fromEntries( + Object.entries(declaration.interfaceAudiences ?? {}).map( + ([name, audiences]) => [ + name, + audiences.flatMap((audience) => audiencePaths[audience]), + ] + ) + ); + + return { + kind: declaration.kind, + path: `^${declaration.root}/(${child})/`, + anyPath: `^${declaration.root}/${child}/`, + sameOwnerPath: `^${declaration.root}/$1/`, + interfacePath: `^${declaration.root}/${child}/${interfacePattern}\\.ts$`, + interfaceCapturePath: `^${declaration.root}/(${child})/${interfacePattern}\\.ts$`, + sameOwnerInterfacePath: `^${declaration.root}/$1/${interfacePattern}\\.ts$`, + interfacePaths, + interfaceImporters, + parentInterfacePath: + declaration.parent === undefined + ? undefined + : `^${declaration.parent}/${interfacePattern}\\.ts$`, + }; +}; + +const makeCollectionRules = ( + collection: CompiledCollection +): IForbiddenRuleType[] => { + const prefix = collection.kind; + const rules: IForbiddenRuleType[] = [ + { + name: `${prefix}-cross-module-internals-private`, + severity: "error", + from: { path: collection.path, pathNot: "\\.css\\.ts$" }, + to: { + path: collection.anyPath, + pathNot: [collection.sameOwnerPath, collection.interfacePath], + }, + }, + { + name: `${prefix}-internals-private`, + severity: "error", + from: { + pathNot: [collection.anyPath, "\\.css\\.ts$"], + }, + to: { + path: collection.anyPath, + pathNot: collection.interfacePath, + }, + }, + { + name: `${prefix}-no-self-interface-imports`, + severity: "error", + from: { + path: collection.path, + pathNot: collection.interfacePath, + }, + to: { path: collection.sameOwnerInterfacePath }, + }, + { + name: `${prefix}-no-cross-module-reexports`, + severity: "error", + from: { path: collection.interfaceCapturePath }, + to: { + path: ownedModulePath, + pathNot: collection.sameOwnerPath, + dependencyTypes: ["export"], + }, + }, + ]; + + if (collection.kind !== "resource") { + const compositionPath = collection.interfacePaths.composition; + const runtimePath = collection.interfacePaths.runtime; + const viewsPath = collection.interfacePaths.views; + const indexPath = collection.interfacePaths.index; + if ( + compositionPath === undefined || + runtimePath === undefined || + viewsPath === undefined || + indexPath === undefined + ) { + throw new Error(`${collection.kind} is missing a Feature interface role`); + } + + rules.push( + { + name: `${prefix}-composition-importers`, + severity: "error", + from: { + pathNot: collection.interfaceImporters.composition ?? [], + }, + to: { path: compositionPath }, + }, + { + name: `${prefix}-runtime-importers`, + severity: "error", + from: { pathNot: collection.interfaceImporters.runtime ?? [] }, + to: { path: runtimePath }, + }, + { + name: `${prefix}-views-stay-out-of-runtimes`, + severity: "error", + from: { + path: "^(?:src/app/runtime/|src/features/.+/runtime\\.ts$|src/features/.+/state/orchestration/)", + }, + to: { path: viewsPath }, + }, + { + name: `${prefix}-index-is-headless`, + severity: "error", + from: { path: indexPath }, + to: { path: "/ui/", dependencyTypes: ["export"] }, + } + ); + } + + rules.push( + { + name: + collection.kind === "feature" + ? "style-imports-only-target-styles" + : `${prefix}-style-imports-only-target-styles`, + severity: "error", + from: { path: `${collection.path.slice(0, -1)}.*\\.css\\.ts$` }, + to: { + path: collection.anyPath, + pathNot: [ + collection.sameOwnerPath, + collection.interfacePath, + "\\.css\\.ts$", + ], + }, + }, + { + name: `${prefix}-external-style-imports-only-target-styles`, + severity: "error", + from: { path: "\\.css\\.ts$", pathNot: collection.anyPath }, + to: { + path: collection.anyPath, + pathNot: [collection.interfacePath, "\\.css\\.ts$"], + }, + } + ); + + if (collection.parentInterfacePath !== undefined) { + rules.push({ + name: `${prefix}-no-parent-reexports`, + severity: "error", + from: { path: collection.parentInterfacePath }, + to: { path: collection.anyPath, dependencyTypes: ["export"] }, + }); + } + + return rules; +}; + +const directoryPattern = (path: string) => `^${path.replaceAll("*", "[^/]+")}/`; + +const makeOwnedModuleRules = ( + declaration: OwnedModuleDeclaration +): IForbiddenRuleType[] => { + const interfacePaths = Object.fromEntries( + Object.keys(declaration.interfaces).map((name) => [ + name, + `^${declaration.root}/${name}\\.ts$`, + ]) + ) as Record; + const allInterfaces = `^${declaration.root}/(?:${Object.keys( + declaration.interfaces + ).join("|")})\\.ts$`; + + return [ + { + name: `${declaration.kind}-internals-private`, + severity: "error", + from: { pathNot: `^${declaration.root}/` }, + to: { path: `^${declaration.root}/`, pathNot: allInterfaces }, + }, + { + name: `${declaration.kind}-no-cross-module-reexports`, + severity: "error", + from: { path: allInterfaces }, + to: { + path: ownedModulePath, + pathNot: `^${declaration.root}/`, + dependencyTypes: ["export"], + }, + }, + ...Object.entries(declaration.interfaces).map( + ([name, importers]): IForbiddenRuleType => ({ + name: `${declaration.kind}-${name}-importers`, + severity: "error", + from: { + pathNot: [ + directoryPattern(declaration.root), + ...importers.map(directoryPattern), + ], + }, + to: { + path: interfacePaths[ + name as keyof OwnedModuleDeclaration["interfaces"] + ], + }, + }) + ), + ]; +}; + +const configuration: IConfiguration = { + forbidden: [ + { + name: "undeclared-layer-imports", + severity: "error", + from: {}, + to: { path: undeclaredLayerPath }, + }, + { + name: "undeclared-layer-dependencies", + severity: "error", + from: { path: undeclaredLayerPath }, + to: {}, + }, + ...makeLayerRules(), + { + name: "public-api-domain-contracts-only", + severity: "error", + comment: + "Host declarations may import Domain only through explicit contract.ts files.", + from: { path: "^src/public-api/" }, + to: { + path: "^src/domain/", + pathNot: "^src/domain/.+/contract\\.ts$", + }, + }, + { + name: "shared-public-theme-contract-only", + severity: "error", + comment: + "Only shared theme implementation modules may implement the host-facing theme contract.", + from: { + path: "^src/shared/", + pathNot: "^src/shared/styles/(?:theme|tokens)/", + }, + to: { path: "^src/public-api/" }, + }, + { + name: "shared-theme-imports-only-theme-contract", + severity: "error", + comment: + "Shared theme implementation may depend only on the exact host-facing theme contract.", + from: { path: "^src/shared/styles/(?:theme|tokens)/" }, + to: { + path: "^src/public-api/", + pathNot: "^src/public-api/theme\\.ts$", + }, + }, + ...makeGeneratedRuntimeRules(), + ...architecturePolicy.moduleCollections + .map(compileCollection) + .flatMap(makeCollectionRules), + ...architecturePolicy.ownedModules.flatMap(makeOwnedModuleRules), + { + name: "no-circular-dependencies", + severity: "error", + from: {}, + to: { circular: true }, + }, + { + name: "no-unresolved-imports", + severity: "error", + from: {}, + to: { couldNotResolve: true }, + }, + ], + options: { + combinedDependencies: true, + doNotFollow: { path: "node_modules" }, + includeOnly: ["^src/"], + moduleSystems: ["es6", "cjs"], + progress: { type: "none" }, + tsConfig: { fileName: "tsconfig.json" }, + tsPreCompilationDeps: true, + }, +}; + +export default configuration; diff --git a/packages/widget/scripts/architecture/module-cycle-baseline.ts b/packages/widget/scripts/architecture/module-cycle-baseline.ts new file mode 100644 index 000000000..e3e716efa --- /dev/null +++ b/packages/widget/scripts/architecture/module-cycle-baseline.ts @@ -0,0 +1,9 @@ +/** + * Allowed owned-Module cycle edges during an active cycle-removal slice. + * + * Used by `check-architecture.ts`. The steady-state graph has no accepted + * cycles. + */ +import type { OwnedModuleEdge } from "./module-cycle-policy"; + +export const moduleCycleBaseline: ReadonlyArray = []; diff --git a/packages/widget/scripts/architecture/module-cycle-policy.ts b/packages/widget/scripts/architecture/module-cycle-policy.ts new file mode 100644 index 000000000..dbebca9a7 --- /dev/null +++ b/packages/widget/scripts/architecture/module-cycle-policy.ts @@ -0,0 +1,240 @@ +/** + * Maps `src` files to owned Modules and reports cycles against the baseline. + * + * Used by `check-architecture.ts`. + */ +import type { ICruiseResult } from "dependency-cruiser"; +import type { + ModuleCollectionDeclaration, + OwnedModuleDeclaration, +} from "./dependency-cruiser.config.mts"; + +export type ModuleCyclePolicy = Readonly<{ + moduleCollections: ReadonlyArray; + ownedModules: ReadonlyArray; +}>; + +export type OwnedModuleEdge = Readonly<{ + from: string; + to: string; +}>; + +type ConcreteModuleEdge = OwnedModuleEdge & + Readonly<{ + source: string; + resolved: string; + }>; + +type OwnedModuleCycle = Readonly<{ + edges: ReadonlyArray; + modules: ReadonlyArray; + path: ReadonlyArray; +}>; + +export type ModuleCycleCheck = Readonly<{ + cycles: ReadonlyArray; + staleBaseline: ReadonlyArray; + unbaselinedEdges: ReadonlyArray; +}>; + +const edgeKey = ({ from, to }: OwnedModuleEdge) => `${from} -> ${to}`; + +const matchesCollection = ( + source: string, + declaration: ModuleCollectionDeclaration +) => { + const prefix = `${declaration.root}/`; + if (!source.startsWith(prefix)) return null; + + const child = source.slice(prefix.length).split("/")[0]; + if (!child || declaration.excludedChildren?.includes(child)) return null; + + return `${declaration.root}/${child}`; +}; + +export const resolveOwnedModule = ( + source: string, + policy: ModuleCyclePolicy +) => { + const collectionOwners = policy.moduleCollections.flatMap((declaration) => { + const owner = matchesCollection(source, declaration); + return owner ? [owner] : []; + }); + const singularOwners = policy.ownedModules.flatMap((declaration) => + source === declaration.root || source.startsWith(`${declaration.root}/`) + ? [declaration.root] + : [] + ); + + return ( + [...collectionOwners, ...singularOwners].sort( + (left, right) => right.length - left.length + )[0] ?? null + ); +}; + +const buildOwnedGraph = ( + modules: ICruiseResult["modules"], + policy: ModuleCyclePolicy +) => { + const graph = new Map>(); + const concreteEdges = new Map(); + + for (const module of modules) { + const from = resolveOwnedModule(module.source, policy); + if (!from) continue; + + graph.set(from, graph.get(from) ?? new Set()); + for (const dependency of module.dependencies) { + const to = resolveOwnedModule(dependency.resolved, policy); + if (!to || to === from) continue; + + graph.set(to, graph.get(to) ?? new Set()); + graph.get(from)?.add(to); + const edge = { + from, + resolved: dependency.resolved, + source: module.source, + to, + }; + const key = edgeKey(edge); + concreteEdges.set(key, [...(concreteEdges.get(key) ?? []), edge]); + } + } + + return { concreteEdges, graph } as const; +}; + +const stronglyConnectedComponents = ( + graph: ReadonlyMap> +) => { + const visited = new Set(); + const order: string[] = []; + const visit = (node: string) => { + if (visited.has(node)) return; + visited.add(node); + for (const next of graph.get(node) ?? []) visit(next); + order.push(node); + }; + for (const node of graph.keys()) visit(node); + + const reverse = new Map>(); + for (const node of graph.keys()) reverse.set(node, new Set()); + for (const [from, targets] of graph) { + for (const to of targets) reverse.get(to)?.add(from); + } + + const assigned = new Set(); + const components: string[][] = []; + const collect = (node: string, component: string[]) => { + if (assigned.has(node)) return; + assigned.add(node); + component.push(node); + for (const next of reverse.get(node) ?? []) collect(next, component); + }; + for (const node of order.toReversed()) { + if (assigned.has(node)) continue; + const component: string[] = []; + collect(node, component); + components.push(component.sort()); + } + + return components.filter((component) => component.length > 1); +}; + +const findCyclePath = ( + graph: ReadonlyMap>, + component: ReadonlyArray +) => { + const members = new Set(component); + const start = component[0]; + if (!start) return []; + + const search = ( + node: string, + path: ReadonlyArray, + pathMembers: ReadonlySet + ): ReadonlyArray | null => { + for (const next of graph.get(node) ?? []) { + if (!members.has(next)) continue; + if (next === start) return [...path, start]; + if (pathMembers.has(next)) continue; + + const found = search( + next, + [...path, next], + new Set([...pathMembers, next]) + ); + if (found) return found; + } + return null; + }; + + return search(start, [start], new Set([start])) ?? component; +}; + +const uniqueOwnedEdges = (edges: ReadonlyArray) => + Array.from( + new Map( + edges.map((edge) => [edgeKey(edge), { from: edge.from, to: edge.to }]) + ).values() + ).sort((left, right) => edgeKey(left).localeCompare(edgeKey(right))); + +export const checkOwnedModuleCycles = ({ + baseline, + cruiseResult, + policy, +}: Readonly<{ + baseline: ReadonlyArray; + cruiseResult: Pick; + policy: ModuleCyclePolicy; +}>): ModuleCycleCheck => { + const { concreteEdges, graph } = buildOwnedGraph( + cruiseResult.modules, + policy + ); + const cycles = stronglyConnectedComponents(graph).map((modules) => { + const members = new Set(modules); + const edges = Array.from(concreteEdges.values()) + .flat() + .filter((edge) => members.has(edge.from) && members.has(edge.to)) + .sort((left, right) => + `${edgeKey(left)} ${left.source} ${left.resolved}`.localeCompare( + `${edgeKey(right)} ${right.source} ${right.resolved}` + ) + ); + return { edges, modules, path: findCyclePath(graph, modules) }; + }); + const activeEdges = uniqueOwnedEdges(cycles.flatMap((cycle) => cycle.edges)); + const activeKeys = new Set(activeEdges.map(edgeKey)); + const baselineKeys = new Set(baseline.map(edgeKey)); + + return { + cycles, + staleBaseline: baseline.filter((edge) => !activeKeys.has(edgeKey(edge))), + unbaselinedEdges: activeEdges.filter( + (edge) => !baselineKeys.has(edgeKey(edge)) + ), + }; +}; + +export const formatModuleCycleCheck = (check: ModuleCycleCheck) => { + const lines: string[] = []; + for (const cycle of check.cycles) { + lines.push(`Owned Module cycle: ${cycle.path.join(" -> ")}`); + for (const edge of cycle.edges) { + lines.push( + ` ${edge.from} -> ${edge.to}: ${edge.source} -> ${edge.resolved}` + ); + } + } + if (check.unbaselinedEdges.length > 0) { + lines.push("Unbaselined cyclic Module edges:"); + for (const edge of check.unbaselinedEdges) lines.push(` ${edgeKey(edge)}`); + } + if (check.staleBaseline.length > 0) { + lines.push("Stale Module-cycle baseline edges (remove them):"); + for (const edge of check.staleBaseline) lines.push(` ${edgeKey(edge)}`); + } + return lines.join("\n"); +}; diff --git a/packages/widget/scripts/declarations/check-package-types.ts b/packages/widget/scripts/declarations/check-package-types.ts new file mode 100644 index 000000000..1533e6693 --- /dev/null +++ b/packages/widget/scripts/declarations/check-package-types.ts @@ -0,0 +1,416 @@ +/** + * Type-checks the packed Widget from isolated consumer projects. + * + * Run via `pnpm check:package-types` after `build:types`. Source checks can + * resolve unpublished files and undeclared dependencies from the workspace. + * These consumers install only the tarball and their declared peer setup, so + * resolution follows the package manifest and packed contents. + */ + +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { + access, + copyFile, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { widgetRoot } from "../paths"; +import { + declarationSpecifiers, + resolveDeclarationImport, +} from "./declaration-graph.ts"; + +const fixtureRoot = join(widgetRoot, "tests/package-types"); +const requireFromWidget = createRequire(import.meta.url); +const typescriptCli = requireFromWidget.resolve("typescript/lib/tsc.js"); + +type ModuleResolution = "Bundler" | "NodeNext"; +type Consumer = { + readonly allowedExternalTypes: ReadonlyArray; + readonly declarationEntry: string; + readonly fixture: string; + readonly label: string; + readonly reactPeers: boolean; +}; + +const consumers = [ + { + allowedExternalTypes: ["effect"], + declarationEntry: "dist/types/public-api/index.bundle.d.ts", + fixture: "bundle-consumer.ts", + label: "standalone bundle", + reactPeers: false, + }, + { + allowedExternalTypes: ["effect", "react"], + declarationEntry: "dist/types/public-api/index.package.d.ts", + fixture: "package-consumer.ts", + label: "React package", + reactPeers: true, + }, +] as const satisfies ReadonlyArray; + +const moduleResolutions = ["Bundler", "NodeNext"] as const; + +const packageNameFromSpecifier = (specifier: string) => { + const firstSeparator = specifier.indexOf("/"); + if (!specifier.startsWith("@") || firstSeparator === -1) { + return firstSeparator === -1 + ? specifier + : specifier.slice(0, firstSeparator); + } + + const secondSeparator = specifier.indexOf("/", firstSeparator + 1); + return secondSeparator === -1 + ? specifier + : specifier.slice(0, secondSeparator); +}; + +const checkDeclarationGraph = async ( + widgetDirectory: string, + consumer: Consumer +) => { + const declarationRoot = join(widgetDirectory, "dist/types"); + const pending = [join(widgetDirectory, consumer.declarationEntry)]; + const visited = new Set(); + const externalTypes = new Set(); + + while (pending.length > 0) { + const declarationPath = pending.pop(); + if (!declarationPath || visited.has(declarationPath)) continue; + visited.add(declarationPath); + + const sourceText = await readFile(declarationPath, "utf8"); + for (const specifier of declarationSpecifiers( + declarationPath, + sourceText + )) { + if (specifier.startsWith(".")) { + pending.push( + ( + await resolveDeclarationImport({ + declarationRoot, + fromPath: declarationPath, + specifier, + }) + ).declarationPath + ); + } else { + externalTypes.add(packageNameFromSpecifier(specifier)); + } + } + } + + const actual = [...externalTypes].sort(); + const expected = [...consumer.allowedExternalTypes].sort(); + if (actual.join("\n") !== expected.join("\n")) { + throw new Error( + `${consumer.label} declarations reference [${actual.join( + ", " + )}], expected only [${expected.join(", ")}]` + ); + } +}; + +const checkPackedManifest = async ( + widgetDirectory: string, + consumer: Consumer +) => { + const manifest = JSON.parse( + await readFile(join(widgetDirectory, "package.json"), "utf8") + ) as { + readonly dependencies?: Readonly>; + readonly peerDependencies?: Readonly>; + }; + + for (const packageName of consumer.allowedExternalTypes) { + const classification = + packageName === "react" ? "peerDependencies" : "dependencies"; + if (!manifest[classification]?.[packageName]) { + throw new Error( + `${consumer.label} declarations require ${packageName}, but the packed ` + + `manifest does not declare it in ${classification}` + ); + } + } +}; + +const run = async ( + label: string, + command: string, + args: ReadonlyArray, + cwd: string, + options: { readonly quiet?: boolean } = {} +) => { + console.log(`[package-types] ${label}`); + console.log(`[package-types] $ ${command} ${args.join(" ")}`); + + const child = spawn(command, [...args], { + cwd, + env: process.env, + stdio: options.quiet + ? (["inherit", "ignore", "inherit"] as const) + : "inherit", + }); + const [code, signal] = await once(child, "exit"); + + if (code !== 0) { + throw new Error( + `${label} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}` + ); + } +}; + +const packageManagerCommand = () => { + const pnpmCli = process.env.npm_execpath; + if (pnpmCli) { + return { + argsPrefix: [pnpmCli], + command: process.execPath, + } as const; + } + + return { argsPrefix: [], command: "pnpm" } as const; +}; + +const runPnpm = async ( + label: string, + args: ReadonlyArray, + cwd: string, + options?: { readonly quiet?: boolean } +) => { + const { argsPrefix, command } = packageManagerCommand(); + await run(label, command, [...argsPrefix, ...args], cwd, options); +}; + +const findInstalledPackage = async (packageName: string) => { + const linkedDirectory = join( + widgetRoot, + "node_modules", + ...packageName.split("/") + ); + try { + const packageJson = JSON.parse( + await readFile(join(linkedDirectory, "package.json"), "utf8") + ) as { readonly name?: string; readonly version?: string }; + if (packageJson.name === packageName && packageJson.version) { + return { + directory: await realpath(linkedDirectory), + version: packageJson.version, + } as const; + } + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) { + throw error; + } + } + + let currentDirectory = dirname(requireFromWidget.resolve(packageName)); + + while (currentDirectory !== dirname(currentDirectory)) { + const packageJsonPath = join(currentDirectory, "package.json"); + try { + const packageJson = JSON.parse( + await readFile(packageJsonPath, "utf8") + ) as { readonly name?: string; readonly version?: string }; + if (packageJson.name === packageName && packageJson.version) { + return { + directory: currentDirectory, + version: packageJson.version, + } as const; + } + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) { + throw error; + } + } + + currentDirectory = dirname(currentDirectory); + } + + throw new Error(`Could not resolve the installed version of ${packageName}`); +}; + +const writeJson = async (path: string, value: unknown) => { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +}; + +const writeConsumerPackage = async ( + directory: string, + tarballPath: string, + reactPeers: boolean +) => { + const dependencies: Record = { + "@stakekit/widget": `file:${tarballPath}`, + }; + const devDependencies: Record = {}; + + if (reactPeers) { + for (const packageName of ["react", "react-dom"] as const) { + dependencies[packageName] = ( + await findInstalledPackage(packageName) + ).version; + } + for (const packageName of ["@types/react", "@types/react-dom"] as const) { + devDependencies[packageName] = ( + await findInstalledPackage(packageName) + ).version; + } + } + + await writeJson(join(directory, "package.json"), { + name: "@stakekit/package-types-consumer", + private: true, + type: "module", + dependencies, + ...(reactPeers ? { devDependencies } : {}), + }); +}; + +const writeTsConfig = async ( + directory: string, + moduleResolution: ModuleResolution +) => { + const module = moduleResolution === "NodeNext" ? "NodeNext" : "ESNext"; + const configPath = join( + directory, + `tsconfig.${moduleResolution.toLowerCase()}.json` + ); + + await writeJson(configPath, { + compilerOptions: { + exactOptionalPropertyTypes: true, + lib: ["ES2022", "ESNext.Disposable", "DOM", "DOM.Iterable"], + module, + moduleResolution, + noEmit: true, + skipLibCheck: false, + strict: true, + target: "ES2022", + types: [], + verbatimModuleSyntax: true, + }, + files: ["./consumer.ts"], + }); + + return configPath; +}; + +const linkConsumerPeer = async (directory: string, packageName: string) => { + const peer = await findInstalledPackage(packageName); + const pathSegments = packageName.split("/"); + const peerPath = join(directory, "node_modules", ...pathSegments); + await mkdir(dirname(peerPath), { recursive: true }); + await symlink(peer.directory, peerPath, "dir"); +}; + +const installPackedWidget = async ( + directory: string, + tarballPath: string, + consumer: Consumer +) => { + const widgetDirectory = join(directory, "node_modules/@stakekit/widget"); + await mkdir(widgetDirectory, { recursive: true }); + await run( + "extract packed Widget", + "tar", + ["-xzf", tarballPath, "-C", widgetDirectory, "--strip-components", "1"], + directory + ); + await checkPackedManifest(widgetDirectory, consumer); + + for (const packageName of consumer.allowedExternalTypes) { + if (packageName !== "react") { + await linkConsumerPeer(widgetDirectory, packageName); + } + } + + if (consumer.reactPeers) { + for (const packageName of [ + "react", + "react-dom", + "@types/react", + "@types/react-dom", + ] as const) { + await linkConsumerPeer(directory, packageName); + } + } + + return widgetDirectory; +}; + +const checkConsumer = async ( + rootDirectory: string, + tarballPath: string, + consumer: Consumer +) => { + const consumerDirectory = join( + rootDirectory, + consumer.reactPeers ? "package-consumer" : "bundle-consumer" + ); + await mkdir(consumerDirectory, { recursive: true }); + await copyFile( + join(fixtureRoot, consumer.fixture), + join(consumerDirectory, "consumer.ts") + ); + await writeConsumerPackage( + consumerDirectory, + tarballPath, + consumer.reactPeers + ); + const widgetDirectory = await installPackedWidget( + consumerDirectory, + tarballPath, + consumer + ); + await checkDeclarationGraph(widgetDirectory, consumer); + + for (const moduleResolution of moduleResolutions) { + const configPath = await writeTsConfig(consumerDirectory, moduleResolution); + await run( + `type-check ${consumer.label} with ${moduleResolution} resolution`, + process.execPath, + [typescriptCli, "--project", configPath, "--pretty", "false"], + consumerDirectory + ); + } +}; + +const main = async () => { + const temporaryRoot = await mkdtemp( + join(tmpdir(), "stakekit-package-types-") + ); + const tarballPath = join(temporaryRoot, "stakekit-widget.tgz"); + + try { + await access(join(widgetRoot, "dist/types/public-api/index.package.d.ts")); + await access(join(widgetRoot, "dist/types/public-api/index.bundle.d.ts")); + await runPnpm("pack Widget", ["pack", "--out", tarballPath], widgetRoot, { + quiet: true, + }); + for (const consumer of consumers) { + await checkConsumer(temporaryRoot, tarballPath, consumer); + } + + console.log( + "[package-types] Packed declarations resolve for every consumer" + ); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}; + +await main(); diff --git a/packages/widget/scripts/declarations/declaration-graph.ts b/packages/widget/scripts/declarations/declaration-graph.ts new file mode 100644 index 000000000..6dff61abe --- /dev/null +++ b/packages/widget/scripts/declarations/declaration-graph.ts @@ -0,0 +1,148 @@ +/** + * Walks `.d.ts` specifiers and resolves relative declaration imports. + * + * Used by `check-package-types.ts`. + */ +import { access } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import ts from "typescript"; + +const readablePath = async (candidates: ReadonlyArray) => { + for (const candidate of candidates) { + try { + await access(candidate); + return candidate; + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) { + throw error; + } + } + } + + return undefined; +}; + +const assertInsideDeclarationRoot = ( + declarationRoot: string, + declarationPath: string, + specifier: string +) => { + const relativePath = relative(declarationRoot, declarationPath); + if (relativePath.startsWith("..") || isAbsolute(relativePath)) { + throw new Error( + `Declaration import ${specifier} escapes ${declarationRoot}` + ); + } +}; + +const explicitDeclarationPath = (importPath: string, extension: string) => { + if (extension === "mjs") return importPath.replace(/\.mjs$/u, ".d.mts"); + if (extension === "cjs") return importPath.replace(/\.cjs$/u, ".d.cts"); + return importPath.replace(/\.js$/u, ".d.ts"); +}; + +export const resolveDeclarationImport = async ({ + declarationRoot, + fromPath, + specifier, +}: { + readonly declarationRoot: string; + readonly fromPath: string; + readonly specifier: string; +}) => { + const importPath = resolve(dirname(fromPath), specifier); + const explicitRuntimeExtension = specifier.match(/\.(cjs|mjs|js)$/u)?.[1]; + const candidates = explicitRuntimeExtension + ? [ + { + declarationPath: explicitDeclarationPath( + importPath, + explicitRuntimeExtension + ), + finalizedSpecifier: specifier, + }, + ] + : [ + { + declarationPath: `${importPath}.d.ts`, + finalizedSpecifier: `${specifier}.js`, + }, + { + declarationPath: `${importPath}.d.mts`, + finalizedSpecifier: `${specifier}.mjs`, + }, + { + declarationPath: `${importPath}.d.cts`, + finalizedSpecifier: `${specifier}.cjs`, + }, + { + declarationPath: join(importPath, "index.d.ts"), + finalizedSpecifier: `${specifier}/index.js`, + }, + ]; + const declarationPath = await readablePath( + candidates.map((candidate) => candidate.declarationPath) + ); + if (!declarationPath) { + throw new Error( + `Declaration import ${specifier} from ${relative( + declarationRoot, + fromPath + )} is missing` + ); + } + + assertInsideDeclarationRoot(declarationRoot, declarationPath, specifier); + const candidate = candidates.find( + (value) => value.declarationPath === declarationPath + ); + if (!candidate) throw new Error(`Unresolved declaration ${declarationPath}`); + + return candidate; +}; + +export const declarationSpecifiers = (path: string, sourceText: string) => { + const source = ts.createSourceFile( + path, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const specifiers = new Set(); + const visit = (node: ts.Node) => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + specifiers.add(node.moduleSpecifier.text); + } + if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) + ) { + specifiers.add(node.argument.literal.text); + } + if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + node.moduleReference.expression && + ts.isStringLiteral(node.moduleReference.expression) + ) { + specifiers.add(node.moduleReference.expression.text); + } + + ts.forEachChild(node, visit); + }; + visit(source); + + for (const reference of source.typeReferenceDirectives) { + specifiers.add(reference.fileName); + } + + return specifiers; +}; diff --git a/packages/widget/scripts/generate-effect-openapi.ts b/packages/widget/scripts/generate-effect-openapi.ts deleted file mode 100644 index 0512244ac..000000000 --- a/packages/widget/scripts/generate-effect-openapi.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -type JsonPatchOperation = { - op: "replace"; - path: string; - value: unknown; -}; - -type SpecConfig = { - name: string; - outputPath: string; - patches: JsonPatchOperation[]; - specFileName: string; - url: string; -}; - -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const widgetRoot = path.resolve(scriptDir, ".."); -const generatedFileHeader = [ - "// @ts-nocheck", - "// biome-ignore-all lint: generated by Effect OpenAPI", - "", -].join("\n"); - -const specs: SpecConfig[] = [ - { - name: "LegacyApi", - url: process.env.LEGACY_API_SPEC_URL ?? "https://api.stakek.it/docs.yaml", - specFileName: "legacy-api.yaml", - outputPath: path.join(widgetRoot, "src/generated/api/legacy.ts"), - patches: [ - // Upstream currently declares regionCode as an object, but production - // payloads and widget geo-block handling use it as a string. - { - op: "replace", - path: "/components/schemas/GeolocationError/properties/regionCode/type", - value: "string", - }, - ], - }, - { - name: "YieldApi", - url: - process.env.YIELD_API_SPEC_URL ?? "https://api.stg.yield.xyz/docs.yaml", - specFileName: "yield-api.yaml", - outputPath: path.join(widgetRoot, "src/generated/api/yield.ts"), - patches: [ - // The spec marks these date-time fields as nullable, but openapigen beta - // currently drops null for nullable date-time strings. Replacing the - // property without format preserves the production response shape. - { - op: "replace", - path: "/components/schemas/TransactionDto/properties/broadcastedAt", - value: { - type: "string", - nullable: true, - description: "When the transaction was broadcasted to the network", - }, - }, - { - op: "replace", - path: "/components/schemas/ActionDto/properties/completedAt", - value: { - type: "string", - nullable: true, - description: "When the action was completed", - }, - }, - ], - }, -]; - -const run = async ( - command: string, - args: string[], - options?: { input?: string } -) => - new Promise<{ stderr: string; stdout: string }>((resolve, reject) => { - const child = spawn(command, args, { - cwd: widgetRoot, - stdio: ["pipe", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.on("error", reject); - child.on("close", (code) => { - if (code === 0) { - resolve({ stdout, stderr }); - return; - } - - reject( - new Error( - [ - `Command failed: ${command} ${args.join(" ")}`, - `Exit code: ${code}`, - stderr.trim(), - ] - .filter(Boolean) - .join("\n") - ) - ); - }); - - if (options?.input) { - child.stdin.write(options.input); - } - child.stdin.end(); - }); - -const fetchSpec = async (spec: SpecConfig) => { - const response = await fetch(spec.url); - - if (!response.ok) { - throw new Error( - `Failed to fetch ${spec.name} spec from ${spec.url}: ${response.status} ${response.statusText}` - ); - } - - return response.text(); -}; - -const generateSpec = async (spec: SpecConfig, tempDir: string) => { - const specPath = path.join(tempDir, spec.specFileName); - const patchPath = path.join(tempDir, `${spec.specFileName}.patch.json`); - - console.log(`Fetching ${spec.name} spec from ${spec.url}`); - await writeFile(specPath, await fetchSpec(spec)); - await writeFile(patchPath, `${JSON.stringify(spec.patches, null, 2)}\n`); - await mkdir(path.dirname(spec.outputPath), { recursive: true }); - - console.log(`Generating ${path.relative(widgetRoot, spec.outputPath)}`); - const { stdout, stderr } = await run("pnpm", [ - "exec", - "openapigen", - "--spec", - specPath, - "--name", - spec.name, - "--format", - "httpclient-type-only", - "--patch", - patchPath, - ]); - - if (stderr.trim()) { - console.warn(stderr.trim()); - } - - if (!stdout.trim()) { - throw new Error(`${spec.name} generation produced empty output`); - } - - await writeFile(spec.outputPath, `${generatedFileHeader}${stdout}`); -}; - -const main = async () => { - const tempDir = await mkdtemp(path.join(tmpdir(), "stakekit-openapi-")); - - try { - for (const spec of specs) { - await generateSpec(spec, tempDir); - } - - await run("pnpm", [ - "exec", - "biome", - "check", - "--write", - "--unsafe", - ...specs.map((spec) => path.relative(widgetRoot, spec.outputPath)), - ]); - } finally { - await rm(tempDir, { force: true, recursive: true }); - } -}; - -main().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; -}); diff --git a/packages/widget/scripts/generated-api/check-generated-schemas.ts b/packages/widget/scripts/generated-api/check-generated-schemas.ts new file mode 100644 index 000000000..84e333602 --- /dev/null +++ b/packages/widget/scripts/generated-api/check-generated-schemas.ts @@ -0,0 +1,34 @@ +/** + * Fails lint when generated `Schema.Union` members include unsafe empty objects. + * + * Run from `pnpm lint`, `pnpm check:generated-schemas`, and `pnpm gen:api`. + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { widgetRoot } from "../paths"; +import { findUnsafeGeneratedSchemaUnions } from "./generated-schema-safety"; + +const generatedSchemaFiles = [ + "src/generated/api/borrow.ts", + "src/generated/api/legacy-schema.ts", + "src/generated/api/yield-schema.ts", +]; + +const diagnostics = ( + await Promise.all( + generatedSchemaFiles.map(async (relativePath) => { + const absolutePath = path.join(widgetRoot, relativePath); + const sourceText = await readFile(absolutePath, "utf8"); + return findUnsafeGeneratedSchemaUnions(sourceText, relativePath).map( + (issue) => + `${relativePath}:${issue.line}:${issue.column} ${issue.message}` + ); + }) + ) +).flat(); + +if (diagnostics.length > 0) { + throw new Error( + `Generated schema safety check failed:\n${diagnostics.join("\n")}` + ); +} diff --git a/packages/widget/scripts/generated-api/generate-effect-openapi.ts b/packages/widget/scripts/generated-api/generate-effect-openapi.ts new file mode 100644 index 000000000..b718c908a --- /dev/null +++ b/packages/widget/scripts/generated-api/generate-effect-openapi.ts @@ -0,0 +1,581 @@ +/** + * Regenerates Effect OpenAPI clients under `src/generated/api`. + * + * Run via `pnpm gen:api`. Fetches StakeKit specs, applies patches, runs + * openapigen, then formats the output. + */ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { parse, stringify } from "yaml"; +import { repositoryRoot, widgetRoot } from "../paths"; +import { + findUnsafeGeneratedSchemaUnions, + normalizeGeneratedTypeOnlySource, + normalizeOpenApiUnionObjects, +} from "./generated-schema-safety"; + +type JsonPatchOperation = { + op: "replace"; + path: string; + value: unknown; +}; + +const nullableScalarPatch = ({ + description, + example, + property, + schema, + type, +}: { + description: string; + example: unknown; + property: string; + schema: string; + type: "number" | "string"; +}): JsonPatchOperation => ({ + op: "replace", + path: `/components/schemas/${schema}/properties/${property}`, + value: { description, example, nullable: true, type }, +}); + +type SpecOutputConfig = { + format: "httpclient" | "httpclient-type-only"; + outputPath: string; + schemaOnly?: boolean; +}; + +type SpecConfig = { + name: string; + outputs: ReadonlyArray; + patches: JsonPatchOperation[]; + prepareSpec?: (contents: string) => string; + specFileName: string; + url: string; +}; + +const binaryPath = (root: string, name: string) => + path.join( + root, + "node_modules/.bin", + `${name}${process.platform === "win32" ? ".CMD" : ""}` + ); +const openApiGeneratorBin = binaryPath(widgetRoot, "openapigen"); +const biomeBin = binaryPath(repositoryRoot, "biome"); +const generatedFileHeader = [ + "// @ts-nocheck", + "// biome-ignore-all lint: generated by Effect OpenAPI", + "", +].join("\n"); + +const specs: SpecConfig[] = [ + { + name: "LegacyApi", + url: process.env.LEGACY_API_SPEC_URL ?? "https://api.stakek.it/docs.yaml", + specFileName: "legacy-api.yaml", + outputs: [ + { + format: "httpclient-type-only", + outputPath: path.join(widgetRoot, "src/generated/api/legacy.ts"), + }, + { + format: "httpclient", + outputPath: path.join(widgetRoot, "src/generated/api/legacy-schema.ts"), + schemaOnly: true, + }, + ], + patches: [ + // Upstream currently declares regionCode as an object, but production + // payloads and widget geo-block handling use it as a string. + { + op: "replace", + path: "/components/schemas/GeolocationError/properties/regionCode/type", + value: "string", + }, + ], + }, + { + name: "YieldApi", + url: + process.env.YIELD_API_SPEC_URL ?? "https://api.stg.yield.xyz/docs.yaml", + specFileName: "yield-api.yaml", + outputs: [ + { + format: "httpclient-type-only", + outputPath: path.join(widgetRoot, "src/generated/api/yield.ts"), + }, + { + format: "httpclient", + outputPath: path.join(widgetRoot, "src/generated/api/yield-schema.ts"), + schemaOnly: true, + }, + ], + patches: [ + // These DTO properties have concrete scalar types in the Yield API + // source, but their Swagger decorators omit the explicit property type. + // Patch the spec so openapigen does not emit `{}` placeholders. + nullableScalarPatch({ + schema: "LiquidityStateDto", + property: "liquidity", + type: "string", + description: "Available liquidity in underlying token units", + example: "250000.00", + }), + nullableScalarPatch({ + schema: "LiquidityStateDto", + property: "utilization", + type: "string", + description: "Utilization rate as a decimal (e.g., 0.8 = 80%)", + example: "0.80", + }), + nullableScalarPatch({ + schema: "YieldFeeConfigurationDto", + property: "managementFeeBps", + type: "number", + description: "Management fee in basis points", + example: 100, + }), + nullableScalarPatch({ + schema: "YieldFeeConfigurationDto", + property: "performanceFeeBps", + type: "number", + description: "Performance fee in basis points", + example: 1000, + }), + nullableScalarPatch({ + schema: "YieldFeeConfigurationDto", + property: "depositFeeBps", + type: "number", + description: "Deposit fee in basis points", + example: 0, + }), + nullableScalarPatch({ + schema: "YieldFeeConfigurationDto", + property: "allocatorVaultContractAddress", + type: "string", + description: "Partner allocator vault contract address", + example: "0x80ac24aa929eaf5013f6436cda2a7ba190f5cc0b", + }), + nullableScalarPatch({ + schema: "ProviderDto", + property: "tvlUsd", + type: "string", + description: "Total TVL across the entire provider in USD", + example: "10,200,000", + }), + nullableScalarPatch({ + schema: "ValidatorProviderDto", + property: "tvlUsd", + type: "string", + description: "Total TVL across the entire provider in USD", + example: "10,200,000", + }), + nullableScalarPatch({ + schema: "CuratorDto", + property: "name", + type: "string", + description: "Curator name", + example: "Steakhouse Financial", + }), + nullableScalarPatch({ + schema: "CuratorDto", + property: "description", + type: "string", + description: "Curator description", + example: "Vault curator", + }), + nullableScalarPatch({ + schema: "CuratorDto", + property: "logoURI", + type: "string", + description: "Curator logo URI", + example: "https://example.com/curator.svg", + }), + nullableScalarPatch({ + schema: "YieldRiskCredoraDto", + property: "rating", + type: "string", + description: "Credora rating", + example: "A", + }), + nullableScalarPatch({ + schema: "YieldRiskCredoraDto", + property: "score", + type: "number", + description: "Credora score (1-5)", + example: 4.5, + }), + nullableScalarPatch({ + schema: "YieldRiskCredoraDto", + property: "psl", + type: "number", + description: "Probability of Significant Loss (annualized)", + example: 0.01, + }), + nullableScalarPatch({ + schema: "YieldRiskCredoraDto", + property: "publishDate", + type: "string", + description: "Credora publish date", + example: "2026-01-01", + }), + nullableScalarPatch({ + schema: "YieldRiskCredoraDto", + property: "curator", + type: "string", + description: "Credora curator name", + example: "Credora", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsMetricsDto", + property: "users", + type: "number", + description: "Users count from Staking Rewards risk metrics", + example: 1000, + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "rating", + type: "string", + description: "Staking Rewards rating", + example: "A", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "score", + type: "number", + description: "Staking Rewards score (1-5)", + example: 4, + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "potentialRating", + type: "string", + description: "Staking Rewards potential rating", + example: "A+", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "potentialScore", + type: "number", + description: "Staking Rewards potential score (1-5)", + example: 4.5, + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "ratedAt", + type: "string", + description: "Date when rating was assessed", + example: "2026-01-01", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "ratedSince", + type: "string", + description: "Date since product has been rated", + example: "2025-01-01", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "profileUrl", + type: "string", + description: "Staking Rewards product profile URL", + example: "https://stakingrewards.com/provider", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "reportUrl", + type: "string", + description: "Staking Rewards full report URL", + example: "https://stakingrewards.com/report", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "providerName", + type: "string", + description: "Staking Rewards provider name", + example: "Provider", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "version", + type: "string", + description: "Staking Rewards methodology version", + example: "v1", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "type", + type: "string", + description: "Staking Rewards product type", + example: "validator", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "chain", + type: "string", + description: "Chain label returned by Staking Rewards", + example: "ethereum", + }), + nullableScalarPatch({ + schema: "YieldRiskStakingRewardsDto", + property: "contractAddress", + type: "string", + description: "Contract address returned by Staking Rewards", + example: "0x0000000000000000000000000000000000000001", + }), + { + op: "replace", + path: "/components/schemas/BalanceDto/properties/priceRange", + value: { + type: "object", + description: + "Price range for concentrated liquidity positions in tokens[1]/tokens[0] format", + properties: { + min: { type: "string" }, + max: { type: "string" }, + }, + required: ["min", "max"], + }, + }, + // The spec marks these date-time fields as nullable, but openapigen beta + // currently drops null for nullable date-time strings. Replacing the + // property without format preserves the production response shape. + { + op: "replace", + path: "/components/schemas/TransactionDto/properties/broadcastedAt", + value: { + type: "string", + nullable: true, + description: "When the transaction was broadcasted to the network", + }, + }, + { + op: "replace", + path: "/components/schemas/ActionDto/properties/completedAt", + value: { + type: "string", + nullable: true, + description: "When the action was completed", + }, + }, + ], + }, + { + name: "BorrowApi", + url: + process.env.BORROW_API_SPEC_URL ?? "https://borrow.yield.xyz/docs.json", + specFileName: "borrow-api.json", + outputs: [ + { + format: "httpclient-type-only", + outputPath: path.join(widgetRoot, "src/generated/api/borrow-client.ts"), + }, + { + format: "httpclient", + outputPath: path.join(widgetRoot, "src/generated/api/borrow.ts"), + schemaOnly: true, + }, + ], + patches: [], + prepareSpec: (contents) => + contents.replaceAll( + '"allOf":[{"$ref":"#/components/schemas/ArgumentSchemaPropertyDto"}]', + '"type":"object"' + ), + }, +]; + +const run = async ( + command: string, + args: string[], + options?: { input?: string } +) => + new Promise<{ stderr: string; stdout: string }>((resolve, reject) => { + const child = spawn(command, args, { + cwd: widgetRoot, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + + reject( + new Error( + [ + `Command failed: ${command} ${args.join(" ")}`, + `Exit code: ${code}`, + stderr.trim(), + ] + .filter(Boolean) + .join("\n") + ) + ); + }); + + if (options?.input) { + child.stdin.write(options.input); + } + child.stdin.end(); + }); + +const fetchSpec = async (spec: SpecConfig) => { + const response = await fetch(spec.url); + + if (!response.ok) { + throw new Error( + `Failed to fetch ${spec.name} spec from ${spec.url}: ${response.status} ${response.statusText}` + ); + } + + return response.text(); +}; + +const prepareSpecContents = (spec: SpecConfig, contents: string) => { + const document = parse(spec.prepareSpec?.(contents) ?? contents) as unknown; + normalizeOpenApiUnionObjects(document); + + return spec.specFileName.endsWith(".json") + ? `${JSON.stringify(document, null, 2)}\n` + : // openapigen's YAML parser rejects folded plain scalars, so keep every + // scalar on a single line instead of wrapping at the default width. + stringify(document, { lineWidth: 0 }); +}; + +const extractSchemaOnlyOutput = (output: string, specName: string) => { + const clientMarker = "\nexport interface OperationConfig {"; + const clientStart = output.indexOf(clientMarker); + + if (clientStart === -1) { + throw new Error( + `${specName} schema generation did not contain the expected client marker` + ); + } + + return `${output + .slice(0, clientStart) + .replace(/^import \* as Data from "effect\/Data";?\r?\n/m, "") + .replace(/^import \* as Effect from "effect\/Effect";?\r?\n/m, "") + .replace(/^import type \{ SchemaError \} from "effect\/Schema";?\r?\n/m, "") + .replace( + /^import(?: type)? \* as HttpClient[^\n]+"effect\/unstable\/http\/[^\n]+\r?\n/gm, + "" + ) + .trimEnd()}\n`; +}; + +const generateSpec = async (spec: SpecConfig, tempDir: string) => { + const specPath = path.join(tempDir, spec.specFileName); + const patchPath = path.join(tempDir, `${spec.specFileName}.patch.json`); + + console.log(`Fetching ${spec.name} spec from ${spec.url}`); + const specContents = await fetchSpec(spec); + await writeFile(specPath, prepareSpecContents(spec, specContents)); + await writeFile(patchPath, `${JSON.stringify(spec.patches, null, 2)}\n`); + + for (const output of spec.outputs) { + await mkdir(path.dirname(output.outputPath), { recursive: true }); + + console.log( + `Generating ${path.relative(widgetRoot, output.outputPath)} (${output.format})` + ); + const { stdout, stderr } = await run(openApiGeneratorBin, [ + "--spec", + specPath, + "--name", + spec.name, + "--format", + output.format, + "--patch", + patchPath, + ]); + + if (stderr.trim()) { + console.warn(stderr.trim()); + } + + if (!stdout.trim()) { + throw new Error( + `${spec.name} ${output.format} generation produced empty output` + ); + } + + const generatedOutput = output.schemaOnly + ? extractSchemaOnlyOutput(stdout, spec.name) + : normalizeGeneratedTypeOnlySource(stdout); + + if (output.schemaOnly) { + const issues = findUnsafeGeneratedSchemaUnions( + generatedOutput, + path.relative(widgetRoot, output.outputPath) + ); + if (issues.length > 0) { + throw new Error( + `${spec.name} generated unsafe schema unions:\n${issues + .map((issue) => `${issue.line}:${issue.column} ${issue.message}`) + .join("\n")}` + ); + } + } + + await writeFile( + output.outputPath, + `${generatedFileHeader}${generatedOutput}` + ); + } +}; + +const main = async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "stakekit-openapi-")); + const requestedSpecs = new Set(process.argv.slice(2)); + const selectedSpecs = requestedSpecs.size + ? specs.filter((spec) => requestedSpecs.has(spec.name)) + : specs; + + if (!selectedSpecs.length) { + throw new Error( + `No OpenAPI specs matched: ${[...requestedSpecs].join(", ")}` + ); + } + + try { + for (const spec of selectedSpecs) { + await generateSpec(spec, tempDir); + } + + await run(biomeBin, [ + "check", + "--write", + "--unsafe", + ...selectedSpecs.flatMap((spec) => + spec.outputs.map((output) => + path.relative(widgetRoot, output.outputPath) + ) + ), + ]); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } +}; + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/widget/scripts/generated-api/generated-schema-safety.ts b/packages/widget/scripts/generated-api/generated-schema-safety.ts new file mode 100644 index 000000000..ddd763ed5 --- /dev/null +++ b/packages/widget/scripts/generated-api/generated-schema-safety.ts @@ -0,0 +1,199 @@ +/** + * OpenAPI and generated-source checks shared by `gen:api` and the schema + * safety script. + */ +import ts from "typescript"; + +type JsonObject = Record; + +type GeneratedSchemaIssue = { + readonly column: number; + readonly line: number; + readonly message: string; +}; + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isUnboundedObjectSchema = (value: unknown): value is JsonObject => { + if (!isJsonObject(value) || value.type !== "object") return false; + if (value.additionalProperties !== undefined) return false; + + return ( + value.properties === undefined || + (isJsonObject(value.properties) && + Object.keys(value.properties).length === 0) + ); +}; + +/** + * OpenAPI objects allow additional properties unless explicitly disabled. + * openapigen closes object schemas when the keyword is omitted, which turns + * arbitrary object members in unions into `Schema.Struct({})`. + */ +export const normalizeOpenApiUnionObjects = (document: unknown): number => { + let normalizedCount = 0; + + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!isJsonObject(value)) return; + + for (const unionKeyword of ["oneOf", "anyOf"] as const) { + const members = value[unionKeyword]; + if (!Array.isArray(members)) continue; + + for (const member of members) { + if (!isUnboundedObjectSchema(member)) continue; + member.additionalProperties = true; + normalizedCount += 1; + } + } + + for (const child of Object.values(value)) visit(child); + }; + + visit(document); + return normalizedCount; +}; + +export const normalizeGeneratedTypeOnlySource = (sourceText: string): string => + sourceText.replaceAll("Schema.Json", "unknown"); + +const schemaMemberName = (expression: ts.Expression): string | undefined => { + let current = expression; + + while ( + ts.isCallExpression(current) && + ts.isPropertyAccessExpression(current.expression) && + (current.expression.name.text === "annotate" || + current.expression.name.text === "pipe") + ) { + current = current.expression.expression; + } + + if ( + ts.isPropertyAccessExpression(current) && + current.expression.getText() === "Schema" + ) { + return current.name.text; + } + + if ( + ts.isCallExpression(current) && + ts.isPropertyAccessExpression(current.expression) && + current.expression.expression.getText() === "Schema" + ) { + return current.expression.name.text; + } +}; + +const unwrapSchemaMember = (expression: ts.Expression): ts.Expression => { + let current = expression; + + while ( + ts.isCallExpression(current) && + ts.isPropertyAccessExpression(current.expression) && + (current.expression.name.text === "annotate" || + current.expression.name.text === "pipe") + ) { + current = current.expression.expression; + } + + return current; +}; + +const isEmptyStruct = (expression: ts.Expression): boolean => { + const current = unwrapSchemaMember(expression); + if (!ts.isCallExpression(current)) return false; + if (!ts.isPropertyAccessExpression(current.expression)) return false; + if (current.expression.expression.getText() !== "Schema") return false; + if (current.expression.name.text !== "Struct") return false; + + const fields = current.arguments[0]; + return ( + fields !== undefined && + ts.isObjectLiteralExpression(fields) && + fields.properties.length === 0 + ); +}; + +const isOneOfOptions = (expression: ts.Expression | undefined): boolean => { + if (!expression || !ts.isObjectLiteralExpression(expression)) return false; + + return expression.properties.some( + (property) => + ts.isPropertyAssignment(property) && + property.name.getText() === "mode" && + ts.isStringLiteral(property.initializer) && + property.initializer.text === "oneOf" + ); +}; + +const overlappingPrimitiveMembers = new Set([ + "BigInt", + "Boolean", + "Literal", + "Literals", + "Number", + "String", +]); + +export const findUnsafeGeneratedSchemaUnions = ( + sourceText: string, + fileName = "generated-schema.ts" +): ReadonlyArray => { + const sourceFile = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const issues: GeneratedSchemaIssue[] = []; + + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.expression.getText() === "Schema" && + node.expression.name.text === "Union" + ) { + const membersArgument = node.arguments[0]; + if (membersArgument && ts.isArrayLiteralExpression(membersArgument)) { + const members = [...membersArgument.elements]; + const emptyStruct = members.find( + (member): member is ts.Expression => + ts.isExpression(member) && isEmptyStruct(member) + ); + const hasOverlappingPrimitive = members.some( + (member) => + ts.isExpression(member) && + overlappingPrimitiveMembers.has(schemaMemberName(member) ?? "") + ); + + if ( + emptyStruct && + (hasOverlappingPrimitive || isOneOfOptions(node.arguments[1])) + ) { + const position = sourceFile.getLineAndCharacterOfPosition( + emptyStruct.getStart(sourceFile) + ); + issues.push({ + column: position.character + 1, + line: position.line + 1, + message: + "Unsafe empty object member in Schema.Union; generate Schema.Record(Schema.String, Schema.Unknown) for an arbitrary object", + }); + } + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return issues; +}; diff --git a/packages/widget/scripts/paths.ts b/packages/widget/scripts/paths.ts new file mode 100644 index 000000000..4eb7fd718 --- /dev/null +++ b/packages/widget/scripts/paths.ts @@ -0,0 +1,10 @@ +/** + * Package and repo roots for nested widget scripts. + * + * `widgetRoot` is `packages/widget`. `repositoryRoot` is the monorepo root. + */ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const widgetRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +export const repositoryRoot = join(widgetRoot, "../.."); diff --git a/packages/widget/scripts/smoke/check-production-host-consumption.ts b/packages/widget/scripts/smoke/check-production-host-consumption.ts new file mode 100644 index 000000000..0280d8111 --- /dev/null +++ b/packages/widget/scripts/smoke/check-production-host-consumption.ts @@ -0,0 +1,634 @@ +/** + * Smoke-tests the built widget the way a host app consumes it. + * + * Widget unit/DOM/browser tests import `src/`. Failures such as a host + * rebundling `dist/package`, a missing `dist` file, or a TDZ/module-eval crash + * only show up after `build:package` / `build:bundle` / `build:types`. This + * script is that missing check: it asserts example apps resolve + * `@stakekit/widget` (and `./bundle`) into `packages/widget/dist`, builds those + * hosts, then loads Vite preview and `next start` in Chromium. + * + * Green means the widget shell mounted and a catalog-backed control appeared + * against a live API, with no `pageerror` and no fatal console errors (TDZ, + * missing modules, chunk load, minified React). It is not a journey test. + * + * Run via `pnpm test:smoke`. Requires `VITE_API_KEY` or `NEXT_PUBLIC_API_KEY` + * (or an example env file). Not part of `check`, `test`, or `test:changed`. + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; +import { access, readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { createServer } from "node:net"; +import { join, relative, sep } from "node:path"; +import type { Readable } from "node:stream"; +import { type Browser, chromium } from "playwright"; +import { repositoryRoot, widgetRoot } from "../paths"; + +type Host = { + readonly label: string; + readonly packageName: string; + readonly directory: string; + readonly widgetSpecifiers: ReadonlyArray; +}; + +const hosts = { + vitePackage: { + label: "Vite package entry", + packageName: "@stakekit/with-vite", + directory: join(repositoryRoot, "packages/examples/with-vite"), + widgetSpecifiers: ["@stakekit/widget"], + }, + viteBundle: { + label: "Vite bundle entry", + packageName: "@stakekit/with-vite-bundled", + directory: join(repositoryRoot, "packages/examples/with-vite-bundled"), + widgetSpecifiers: ["@stakekit/widget/bundle"], + }, + next: { + label: "Next.js", + packageName: "@stakekit/with-nextjs", + directory: join(repositoryRoot, "packages/examples/with-nextjs"), + widgetSpecifiers: ["@stakekit/widget"], + }, +} as const satisfies Record; + +const envFileNames = [ + ".env.production.local", + ".env.development.local", + ".env.local", + ".env.production", + ".env.development", + ".env", +] as const; + +const fatalConsolePatterns = [ + /\bCannot access ['"].+['"] before initialization\b/i, + /\bReferenceError:.*before initialization\b/i, + /\bCannot find module\b/i, + /\bModule not found\b/i, + /\bFailed to resolve (?:module|import)\b/i, + /\bFailed to resolve module specifier\b/i, + /\bFailed to fetch dynamically imported module\b/i, + /\b(?:Importing|Loading) (?:a )?module script failed\b/i, + /\bdoes not provide an export named\b/i, + /\bChunkLoadError\b/i, + /\bLoading chunk \S+ failed\b/i, + /\bMinified React error #\d+\b/i, + /\bInvalid hook call\b/i, +]; + +type ProcessEnv = Record; + +type RunningServer = { + readonly boundUrl: Promise; + readonly child: ChildProcess; + readonly exit: Promise<{ + readonly code: number | null; + readonly signal: string | null; + }>; + readonly label: string; +}; + +const runningServers = new Set(); + +// Vite and Next both announce the port they actually bound, which is the only +// trustworthy value: a free port can be taken between reservation and bind. +const announcedUrlPattern = + /\bhttps?:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):(\d+)\b/u; + +const delay = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms).unref(); + }); + +const hasErrorCode = (error: unknown): error is { readonly code: string } => + typeof error === "object" && + error !== null && + "code" in error && + typeof error.code === "string"; + +const assertHostsResolveBuiltWidget = () => { + const distRoot = join(widgetRoot, "dist"); + + for (const host of Object.values(hosts)) { + const requireFromHost = createRequire(join(host.directory, "package.json")); + + for (const specifier of host.widgetSpecifiers) { + const resolved = requireFromHost.resolve(specifier); + if (!resolved.startsWith(`${distRoot}${sep}`)) { + throw new Error( + `${host.label} resolves ${specifier} to ${resolved} instead of packages/widget/dist` + ); + } + + console.log( + `[smoke] ${host.label} resolves ${specifier} to ${relative(repositoryRoot, resolved)}` + ); + } + } +}; + +const assertBuiltWidgetArtifacts = async () => { + const artifactPaths = [ + "dist/package/index.package.js", + "dist/package/index.package.css", + "dist/bundle/index.bundle.js", + "dist/types/public-api/index.package.d.ts", + "dist/types/public-api/index.bundle.d.ts", + ] as const; + + await Promise.all( + artifactPaths.map(async (path) => { + try { + await access(join(widgetRoot, path)); + } catch { + throw new Error( + `Built widget artifact is missing: packages/widget/${path}` + ); + } + }) + ); +}; + +const parseEnvValue = (contents: string, variableName: string) => { + for (const line of contents.split(/\r?\n/u)) { + const match = line.match( + /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/u + ); + if (!match || match[1] !== variableName) { + continue; + } + + const rawValue = match[2]?.trim() ?? ""; + const quote = rawValue.at(0); + if ((quote === '"' || quote === "'") && rawValue.at(-1) === quote) { + return rawValue.slice(1, -1).trim(); + } + + return rawValue.replace(/\s+#.*$/u, "").trim(); + } + + return ""; +}; + +const readEnvCandidate = async (path: string, variableName: string) => { + try { + const contents = await readFile(path, "utf8"); + return parseEnvValue(contents, variableName); + } catch (error) { + if (hasErrorCode(error) && error.code === "ENOENT") { + return ""; + } + throw error; + } +}; + +const reportApiKeySource = (source: string) => { + console.log(`[smoke] Using StakeKit API key from ${source}`); +}; + +const resolveApiKey = async () => { + const processCandidates = [ + ["VITE_API_KEY", process.env.VITE_API_KEY], + ["NEXT_PUBLIC_API_KEY", process.env.NEXT_PUBLIC_API_KEY], + ] as const; + + for (const [source, value] of processCandidates) { + const trimmed = value?.trim(); + if (trimmed) { + reportApiKeySource(`environment variable ${source}`); + return trimmed; + } + } + + for (const host of Object.values(hosts)) { + for (const fileName of envFileNames) { + const path = join(host.directory, fileName); + for (const variableName of ["VITE_API_KEY", "NEXT_PUBLIC_API_KEY"]) { + const value = await readEnvCandidate(path, variableName); + if (value) { + reportApiKeySource( + `${path.slice(repositoryRoot.length + 1)} (${variableName})` + ); + return value; + } + } + } + } + + throw new Error( + [ + "Production-consumer smoke test requires a non-empty StakeKit API key.", + "Set VITE_API_KEY or NEXT_PUBLIC_API_KEY, or add one to an example .env file.", + "The resolved value is passed to every host as both variables.", + ].join(" ") + ); +}; + +const runCommand = async ( + label: string, + args: ReadonlyArray, + env: ProcessEnv +) => { + console.log(`\n[smoke] ${label}`); + const child = spawn("pnpm", [...args], { + cwd: repositoryRoot, + env, + stdio: "inherit", + }); + const [code, signal] = await once(child, "exit"); + if (code !== 0) { + throw new Error( + `${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}` + ); + } +}; + +const getFreePort = () => + new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Could not allocate a smoke-test port")); + return; + } + + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); + +const pipeServerOutput = ( + stream: Readable, + label: string, + onLine: (line: string) => void +) => { + stream.setEncoding("utf8"); + stream.on("data", (chunk: string) => { + for (const line of chunk.trimEnd().split(/\r?\n/u)) { + if (line) { + console.log(`[${label}] ${line}`); + onLine(line); + } + } + }); +}; + +const startServer = ({ + label, + args, + env, +}: { + readonly label: string; + readonly args: ReadonlyArray; + readonly env: ProcessEnv; +}): RunningServer => { + const child = spawn("pnpm", [...args], { + cwd: repositoryRoot, + detached: process.platform !== "win32", + env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let announceBoundUrl!: (url: string) => void; + const boundUrl = new Promise((resolve) => { + announceBoundUrl = resolve; + }); + const handleLine = (line: string) => { + const match = line.match(announcedUrlPattern); + const port = match?.[1]; + if (port) { + announceBoundUrl(`http://127.0.0.1:${port}`); + } + }; + + if (!child.stdout || !child.stderr) { + throw new Error(`${label} did not expose stdout and stderr pipes`); + } + + pipeServerOutput(child.stdout, label, handleLine); + pipeServerOutput(child.stderr, label, handleLine); + + const exit = once(child, "exit").then(([code, signal]) => ({ + code: typeof code === "number" ? code : null, + signal: typeof signal === "string" ? signal : null, + })); + const server = { boundUrl, child, exit, label }; + runningServers.add(server); + return server; +}; + +const stopServer = async (server: RunningServer) => { + if (!runningServers.delete(server) || server.child.exitCode !== null) { + return; + } + + const pid = server.child.pid; + if (pid === undefined) { + await server.exit; + return; + } + + const target = process.platform === "win32" ? pid : -pid; + try { + process.kill(target, "SIGTERM"); + } catch (error) { + if (!hasErrorCode(error) || error.code !== "ESRCH") { + throw error; + } + } + + const stopped = await Promise.race([ + server.exit.then(() => true), + delay(5_000).then(() => false), + ]); + if (stopped) { + return; + } + + try { + process.kill(target, "SIGKILL"); + } catch (error) { + if (!hasErrorCode(error) || error.code !== "ESRCH") { + throw error; + } + } + await server.exit; +}; + +const stopAllServers = async () => { + await Promise.all([...runningServers].map(stopServer)); +}; + +const resolveBoundUrl = async ( + server: RunningServer, + requestedPort: number, + timeoutMs = 120_000 +) => { + const result = await Promise.race([ + server.boundUrl.then((url) => ({ url }) as const), + server.exit.then(({ code, signal }) => ({ + exitedWith: signal ?? code, + })), + delay(timeoutMs).then(() => ({ timedOut: true }) as const), + ]); + + if ("exitedWith" in result) { + throw new Error( + `${server.label} exited before announcing a URL (${result.exitedWith})` + ); + } + if ("timedOut" in result) { + throw new Error( + `${server.label} did not announce a listening URL within ${timeoutMs}ms` + ); + } + + const boundPort = Number(new URL(result.url).port); + if (boundPort !== requestedPort) { + console.log( + `[smoke] ${server.label} bound port ${boundPort} instead of the requested ${requestedPort}; targeting the announced port` + ); + } + + return result.url; +}; + +const waitForServer = async ( + server: RunningServer, + url: string, + timeoutMs = 120_000 +) => { + const deadline = Date.now() + timeoutMs; + let lastFailure = "no response yet"; + + while (Date.now() < deadline) { + const attemptBudgetMs = Math.max(1, Math.min(5_000, deadline - Date.now())); + const result = await Promise.race([ + server.exit.then(({ code, signal }) => ({ + exited: true as const, + message: `${server.label} exited before becoming ready (${signal ?? code})`, + })), + fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(attemptBudgetMs), + }) + .then(() => ({ ready: true as const })) + .catch((error: unknown) => ({ + ready: false as const, + failure: error instanceof Error ? error.message : String(error), + })), + ]); + + if ("exited" in result) { + throw new Error(result.message); + } + if (result.ready) { + return; + } + + lastFailure = result.failure ?? lastFailure; + await delay(250); + } + + throw new Error( + `${server.label} was not ready at ${url} within ${timeoutMs}ms (last failure: ${lastFailure})` + ); +}; + +const assertWidgetLoads = async ( + browser: Browser, + label: string, + url: string +) => { + const context = await browser.newContext(); + const page = await context.newPage(); + const pageErrors: Array = []; + const fatalConsoleErrors: Array = []; + + page.on("pageerror", (error) => { + pageErrors.push(error.stack ?? error.message); + }); + page.on("console", (message) => { + if ( + message.type() === "error" && + fatalConsolePatterns.some((pattern) => pattern.test(message.text())) + ) { + const location = message.location(); + fatalConsoleErrors.push( + `${message.text()}${location.url ? ` (${location.url}:${location.lineNumber})` : ""}` + ); + } + }); + + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 120_000 }); + await page + .locator('[data-rk="widget-container"]') + .waitFor({ state: "visible", timeout: 90_000 }); + await page + .locator( + '[data-rk="earn-live-presentation"] [data-testid="select-token"]' + ) + .waitFor({ state: "visible", timeout: 90_000 }); + await page.waitForTimeout(500); + + if (pageErrors.length > 0 || fatalConsoleErrors.length > 0) { + const failures = [ + ...pageErrors.map((error) => `pageerror: ${error}`), + ...fatalConsoleErrors.map((error) => `fatal console error: ${error}`), + ]; + throw new Error(`${label} browser failures:\n${failures.join("\n")}`); + } + console.log(`[smoke] PASS ${label}`); + } finally { + await context.close(); + } +}; + +const withServer = async ({ + browser, + label, + args, + env, +}: { + readonly browser: Browser; + readonly label: string; + readonly args: (port: number) => ReadonlyArray; + readonly env: ProcessEnv; +}) => { + const port = await getFreePort(); + const server = startServer({ label, args: args(port), env }); + + try { + const url = await resolveBoundUrl(server, port); + await waitForServer(server, url); + await assertWidgetLoads(browser, label, url); + } finally { + await stopServer(server); + } +}; + +const main = async () => { + const apiKey = await resolveApiKey(); + + if (process.argv.includes("--check-key")) { + return; + } + + const env = { + ...process.env, + VITE_API_KEY: apiKey, + NEXT_PUBLIC_API_KEY: apiKey, + }; + + await assertBuiltWidgetArtifacts(); + assertHostsResolveBuiltWidget(); + + await runCommand( + `${hosts.vitePackage.label} build`, + ["--filter", hosts.vitePackage.packageName, "run", "build"], + env + ); + await runCommand( + `${hosts.viteBundle.label} build`, + ["--filter", hosts.viteBundle.packageName, "run", "build"], + env + ); + await runCommand( + `${hosts.next.label} production build`, + ["--filter", hosts.next.packageName, "run", "build"], + env + ); + + const browser = await chromium.launch(); + + try { + await withServer({ + browser, + label: hosts.vitePackage.label, + env, + args: (port) => [ + "--filter", + hosts.vitePackage.packageName, + "exec", + "vite", + "preview", + "--host", + "127.0.0.1", + "--port", + String(port), + "--strictPort", + ], + }); + await withServer({ + browser, + label: hosts.viteBundle.label, + env, + args: (port) => [ + "--filter", + hosts.viteBundle.packageName, + "exec", + "vite", + "preview", + "--host", + "127.0.0.1", + "--port", + String(port), + "--strictPort", + ], + }); + await withServer({ + browser, + label: `${hosts.next.label} production`, + env, + args: (port) => [ + "--filter", + hosts.next.packageName, + "exec", + "next", + "start", + "--hostname", + "127.0.0.1", + "--port", + String(port), + ], + }); + } finally { + await browser.close(); + } +}; + +const handleSignal = (signal: "SIGINT" | "SIGTERM") => { + void stopAllServers().finally(() => { + process.kill(process.pid, signal); + }); +}; + +process.once("SIGINT", () => handleSignal("SIGINT")); +process.once("SIGTERM", () => handleSignal("SIGTERM")); + +const run = async () => { + try { + await main(); + } catch (error) { + console.error( + `[smoke] FAIL ${error instanceof Error ? (error.stack ?? error.message) : error}` + ); + process.exitCode = 1; + } finally { + await stopAllServers(); + } +}; + +void run(); diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx index d1536797e..746c32cff 100644 --- a/packages/widget/src/App.tsx +++ b/packages/widget/src/App.tsx @@ -1,34 +1,52 @@ import "@stakekit/rainbowkit/styles.css"; -import "./translation"; -import "./utils/extend-purify"; -import "./styles/theme/global.css"; -import type { ComponentProps, RefObject } from "react"; -import { createRef, useImperativeHandle, useState } from "react"; +import "./shared/styles/theme/global.css"; +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { type PropsWithChildren, useState } from "react"; import ReactDOM from "react-dom/client"; -import { createMemoryRouter, RouterProvider } from "react-router"; -import { preloadImages } from "./assets/images"; -import { Dashboard } from "./Dashboard"; -import { useNoEnabledYields } from "./hooks/use-no-enabled-yields"; -import NoEnabledYields from "./pages/components/no-enabled-yields"; -import { Providers } from "./providers"; -import { AppContainerProvider } from "./providers/app-container"; -import { SettingsContextProvider, useSettings } from "./providers/settings"; -import type { SettingsProps, VariantProps } from "./providers/settings/types"; -import { useLoadErrorTranslations } from "./translation"; -import { Widget } from "./Widget"; +import { RouterProvider } from "react-router/dom"; +import { ApplicationRouteContentProvider } from "./app/composition/application-route-content"; +import { Providers } from "./app/composition/providers"; +import { SKAtomRegistryProvider } from "./app/composition/providers/atom-runtime"; +import { acquireWidgetInstanceClaim } from "./app/embedding/widget-instance-claim"; +import { WidgetInstanceReactBoundary } from "./app/embedding/widget-instance-react-boundary"; +import { applicationRoutes } from "./app/routes/application-routes"; +import { useApplicationRouteEffects } from "./app/routes/react/use-application-route-effects"; +import { ClassicRoutes } from "./app/routes/ui/classic-routes"; +import { DashboardRoutes } from "./app/routes/ui/dashboard-routes"; +import { applicationRouterAtom } from "./app/runtime/application-router"; +import { walletEnabledNetworksResultAtom } from "./features/wallet/index"; +import { useWidgetConfig } from "./features/widget-configuration/index"; +import { + AppContainer, + NoEnabledYields, +} from "./features/widget-shell/composition"; +import { useUnderMaintenance } from "./features/widget-shell/index"; +import { UnderMaintenance } from "./features/widget-shell/views"; +import type { SKAppProps } from "./public-api/react-types"; +import type { BundledSKWidgetProps } from "./public-api/types"; +import { isLedgerDappBrowserProvider } from "./services/wallet/browser-environment"; +import { preloadImages } from "./shared/assets/images"; preloadImages(); const App = () => { - useLoadErrorTranslations(); + const dashboardVariant = useWidgetConfig("dashboardVariant"); + const enabledNetworks = useAtomValue(walletEnabledNetworksResultAtom); + const underMaintenance = useUnderMaintenance(); + const noEnabledYields = + AsyncResult.isSuccess(enabledNetworks) && enabledNetworks.value.size === 0; - const { dashboardVariant } = useSettings(); + useApplicationRouteEffects(); - const noEnabledYields = useNoEnabledYields(); + if (noEnabledYields && !underMaintenance) return ; - if (noEnabledYields) return ; + const routeContent = (() => { + if (underMaintenance) return ; + return dashboardVariant ? : ; + })(); - return dashboardVariant ? : ; + return routeContent; }; const Root = () => ( @@ -37,61 +55,93 @@ const Root = () => ( ); -export type SKAppProps = SettingsProps & (VariantProps | { variant?: never }); - -export const SKApp = (props: SKAppProps) => { - const variantProps: VariantProps = - props.variant === "zerion" - ? { variant: props.variant, chainModal: props.chainModal } - : { variant: props.variant ?? "default" }; - - const [router] = useState(() => - createMemoryRouter([{ path: "*", Component: Root }]) - ); +const SKAppRouter = () => { + const dashboardVariant = useWidgetConfig("dashboardVariant"); + const router = useAtomValue(applicationRouterAtom); return ( - - + }> + - - + + ); }; -export type BundledSKWidgetProps = SKAppProps & { - ref?: RefObject<{ rerender: (newProps: BundledSKWidgetProps) => void }>; -}; - -const BundledSKWidget = (_props: BundledSKWidgetProps) => { - const [props, setProps] = useState(_props); +export const SKAppRegistryContent = ({ children }: PropsWithChildren) => ( + <> + + {children} + +); - useImperativeHandle(props.ref, () => ({ - rerender: (newProps: BundledSKWidgetProps) => setProps(newProps), - })); +const SKAppProductionContent = ({ + children, + ...hostConfiguration +}: SKAppProps) => { + const [isLedgerDappBrowser] = useState(isLedgerDappBrowserProvider); - return ; + return ( + + {children} + + ); }; +export const SKApp = ({ children, ...hostConfiguration }: SKAppProps) => ( + + + {children} + + +); + +const BundledSKWidget = (props: BundledSKWidgetProps) => ( + +); + export const renderSKWidget = ({ container, ...rest -}: ComponentProps & { +}: BundledSKWidgetProps & { container: Parameters[0]; }) => { - if (!rest.apiKey) throw new Error("API key is required"); - - const root = ReactDOM.createRoot(container); - - const appRef = createRef<{ rerender: () => void }>() as NonNullable< - BundledSKWidgetProps["ref"] - >; - - root.render(); - - return { - rerender: (newProps: SKAppProps) => - appRef.current.rerender({ ...newProps, ref: appRef }), - }; + const releaseClaim = acquireWidgetInstanceClaim( + container.ownerDocument ?? document + ); + let root: ReturnType; + + try { + root = ReactDOM.createRoot(container); + let currentProps = rest; + let unmounted = false; + const render = () => root.render(); + + render(); + + return { + rerender: (newProps: BundledSKWidgetProps) => { + if (unmounted) return; + currentProps = newProps; + render(); + }, + unmount: () => { + if (unmounted) return; + + unmounted = true; + try { + root.unmount(); + } finally { + releaseClaim(); + } + }, + }; + } catch (error) { + releaseClaim(); + throw error; + } }; diff --git a/packages/widget/src/Dashboard.tsx b/packages/widget/src/Dashboard.tsx deleted file mode 100644 index 91fb2ca07..000000000 --- a/packages/widget/src/Dashboard.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Route, Routes } from "react-router"; -import { GlobalModals } from "./components/molecules/global-modals"; -import { ConnectedCheck } from "./navigation/cheks/connected-check"; -// import { RewardsTabPage } from "./pages-dashboard/rewards"; -import { PendingCompletePage } from "./pages/complete/pages/pending-complete.page"; -import { StakeCompletePage } from "./pages/complete/pages/stake-complete.page"; -import { UnstakeCompletePage } from "./pages/complete/pages/unstake-complete.page"; -import { EarnPageContextProvider } from "./pages/details/earn-page/state/earn-page-context"; -import { EarnPageStateUsageBoundaryProvider } from "./pages/details/earn-page/state/earn-page-state-context"; -import { StakeReviewPage } from "./pages/review"; -import { PendingReviewPage } from "./pages/review/pages/pending-review.page"; -import { UnstakeReviewPage } from "./pages/review/pages/unstake-review.page"; -import { StakeStepsPage } from "./pages/steps"; -import { ActivityStepsPage } from "./pages/steps/pages/activity-steps.page"; -import { PendingStepsPage } from "./pages/steps/pages/pending-steps.page"; -import { UnstakeStepsPage } from "./pages/steps/pages/unstake-steps.page"; -import { ActivityTabPage } from "./pages-dashboard/activity"; -import { ActivityDetailsPage } from "./pages-dashboard/activity/activity-details.page"; -import { DashboardWrapper } from "./pages-dashboard/common/components/wrapper"; -import { OverviewPage } from "./pages-dashboard/overview"; -import { EarnPageContent } from "./pages-dashboard/overview/earn-page"; -import { ManagePage } from "./pages-dashboard/overview/manage.page"; -import { PositionDetailsPage } from "./pages-dashboard/position-details"; -import { PositionDetailsActions } from "./pages-dashboard/position-details/components/position-details-actions"; -import { PositionDetailsStakeActions } from "./pages-dashboard/position-details/components/position-details-stake-actions"; -import { DashboardProvider } from "./pages-dashboard/providers/dashboard-context"; -import { useSKLocation } from "./providers/location"; - -const positionDetailsStakeFooterPath = - /^\/positions\/[^/]+\/[^/]+(?:\/stake)?$/; - -export const shouldRegisterDashboardEarnFooterButton = (pathname: string) => - pathname === "/" || positionDetailsStakeFooterPath.test(pathname); - -export const Dashboard = () => { - const { current } = useSKLocation(); - const registerEarnFooterButton = shouldRegisterDashboardEarnFooterButton( - current.pathname - ); - - return ( - - - - - }> - {/* Earn Tab */} - }> - } /> - - }> - } /> - } /> - } /> - - - - {/* Manage Tab */} - } /> - - {/* Position Details */} - } - > - } /> - - {/* Staking */} - - } /> - } /> - } /> - } /> - - - } - /> - - {/* Unstaking */} - - } /> - } /> - } /> - } /> - - - {/* Pending Actions */} - - } /> - } /> - } /> - - - - {/* Rewards Tab */} - {/* } /> */} - - {/* Activity Tab */} - }> - } /> - } - /> - - - - - - - - - ); -}; diff --git a/packages/widget/src/Widget.tsx b/packages/widget/src/Widget.tsx deleted file mode 100644 index 00dfc49a0..000000000 --- a/packages/widget/src/Widget.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { AnimatePresence, LayoutGroup, motion } from "motion/react"; -import { useEffect } from "react"; -import { Navigate, Route, Routes, useNavigate } from "react-router"; -import { GlobalModals } from "./components/molecules/global-modals"; -import { Header } from "./components/molecules/header"; -import { useDetailsMatch } from "./hooks/navigation/use-details-match"; -import { useHandleDeepLinks } from "./hooks/use-handle-deep-links"; -import { useInitParams } from "./hooks/use-init-params"; -import { usePrevious } from "./hooks/use-previous"; -import { useSavedRef } from "./hooks/use-saved-ref"; -import { useUnderMaintenance } from "./hooks/use-under-maintenance"; -import { ConnectedCheck } from "./navigation/cheks/connected-check"; -import { AnimationLayout } from "./navigation/containers/animation-layout"; -import { ActivityCompletePage } from "./pages/complete/pages/activity-complete.page"; -import { PendingCompletePage } from "./pages/complete/pages/pending-complete.page"; -import { StakeCompletePage } from "./pages/complete/pages/stake-complete.page"; -import { UnstakeCompletePage } from "./pages/complete/pages/unstake-complete.page"; -import { Layout } from "./pages/components/layout"; -import { headerContainer } from "./pages/components/layout/styles.css"; -import { PoweredBy } from "./pages/components/powered-by"; -import UnderMaintenance from "./pages/components/under-maintenance"; -import { AnimatedActivityPage } from "./pages/details/activity-page/activity.page"; -import { Details } from "./pages/details/details-page/details.page"; -import { AnimatedEarnPage } from "./pages/details/earn-page/earn.page"; -import { AnimatedPositionsPage } from "./pages/details/positions-page/positions.page"; -import { PositionDetailsPage } from "./pages/position-details"; -import { StakeReviewPage } from "./pages/review"; -import { ActionReviewPage } from "./pages/review/pages/action-review.page"; -import { PendingReviewPage } from "./pages/review/pages/pending-review.page"; -import { UnstakeReviewPage } from "./pages/review/pages/unstake-review.page"; -import { StakeStepsPage } from "./pages/steps"; -import { ActivityStepsPage } from "./pages/steps/pages/activity-steps.page"; -import { PendingStepsPage } from "./pages/steps/pages/pending-steps.page"; -import { UnstakeStepsPage } from "./pages/steps/pages/unstake-steps.page"; -import { useSKLocation } from "./providers/location"; -import { useSKWallet } from "./providers/sk-wallet"; -import { container } from "./style.css"; -import { MaybeWindow } from "./utils/maybe-window"; - -export const Widget = () => { - const underMaintenance = useUnderMaintenance(); - - const { chain, address } = useSKWallet(); - - const prevChain = usePrevious(chain); - const prevAddress = usePrevious(address); - - const { current } = useSKLocation(); - - const pathnameRef = useSavedRef(current.pathname); - const navigateRef = useSavedRef(useNavigate()); - - /** - * On chain change, navigate to home page - */ - useEffect(() => { - if ( - pathnameRef.current !== "/" && - pathnameRef.current !== "/positions" && - pathnameRef.current !== "/activity" && - ((prevChain && chain !== prevChain) || - (prevAddress && address !== prevAddress)) - ) { - MaybeWindow.ifJust((w) => { - const url = new URL(w.location.href); - const newUrl = new URL(w.location.origin); - if (url.searchParams.has("embed")) { - newUrl.searchParams.set("embed", "true"); - } - - w.history.pushState({}, w.document.title, newUrl.href); - navigateRef.current("/", { replace: true }); - }); - } - }, [chain, address, pathnameRef, navigateRef, prevChain, prevAddress]); - - const initTab = useInitParams().data?.tab; - - useEffect(() => { - if (!initTab) return; - - navigateRef.current(initTab === "earn" ? "/" : "/positions"); - }, [initTab, navigateRef]); - - useHandleDeepLinks(); - - const detailsMatch = useDetailsMatch(); - - /** - * Dont unmount details page with tabs - * Handle position details pages in their own Routes - */ - const key = detailsMatch ? "/" : current.key; - - if (underMaintenance) return ; - - return ( - <> - - - -
- - - - - - }> - {/* Home + Tabs */} - }> - } /> - } - /> - } /> - - - }> - {/* Activity flow */} - - } /> - } - /> - } - /> - - - {/* Stake flow */} - - } /> - } /> - } /> - - - {/* Unstake or pending actions flow */} - - } /> - } - /> - - {/* Unstaking */} - - } /> - } /> - } - /> - - - {/* Pending Actions */} - - } /> - } /> - } - /> - - - - - } /> - - - - - - - - - - - - ); -}; diff --git a/packages/widget/src/app/composition/application-route-content.tsx b/packages/widget/src/app/composition/application-route-content.tsx new file mode 100644 index 000000000..17232cb93 --- /dev/null +++ b/packages/widget/src/app/composition/application-route-content.tsx @@ -0,0 +1,10 @@ +import { createContext, type ReactNode, useContext } from "react"; + +const ApplicationRouteContentContext = createContext(null); + +export const ApplicationRouteContentProvider = + ApplicationRouteContentContext.Provider; + +export const ApplicationRouteRoot = () => { + return useContext(ApplicationRouteContentContext); +}; diff --git a/packages/widget/src/app/composition/providers/atom-runtime/index.tsx b/packages/widget/src/app/composition/providers/atom-runtime/index.tsx new file mode 100644 index 000000000..c650ed046 --- /dev/null +++ b/packages/widget/src/app/composition/providers/atom-runtime/index.tsx @@ -0,0 +1,34 @@ +import { RegistryProvider } from "@effect/atom-react"; +import type { PropsWithChildren } from "react"; +import type { RouteObject } from "react-router"; +import type { SKHostConfiguration } from "../../../../public-api/react-types"; +import { applicationRuntimeInitAtom } from "../../../runtime/application-runtime-init"; +import { WidgetConfigBoundaryAdapter } from "../widget-config-binding"; + +type SKAtomRegistryProviderProps = PropsWithChildren<{ + readonly hostConfiguration: SKHostConfiguration; + readonly isLedgerLive?: boolean; + readonly routes: ReadonlyArray; +}>; + +export const SKAtomRegistryProvider = ({ + children, + hostConfiguration, + isLedgerLive = false, + routes, +}: SKAtomRegistryProviderProps) => { + return ( + + + {children} + + + ); +}; diff --git a/packages/widget/src/app/composition/providers/index.tsx b/packages/widget/src/app/composition/providers/index.tsx new file mode 100644 index 000000000..2c18d5a64 --- /dev/null +++ b/packages/widget/src/app/composition/providers/index.tsx @@ -0,0 +1,28 @@ +import type { ComponentProps, PropsWithChildren } from "react"; +import { StrictMode } from "react"; +import { WidgetTranslationProvider } from "../../../features/preferences/composition"; +import { WagmiConfigProvider } from "../../../features/wallet/composition"; +import { MountAnimationEffects } from "./mount-animation"; +import { ThirdPartyQueryClientProvider } from "./query-client"; +import { RainbowProvider } from "./rainbow"; +import { ThemeWrapper } from "./theme-wrapper"; +import { WidgetPresentationAdapter } from "./widget-presentation"; + +export const Providers = ({ + children, +}: PropsWithChildren & ComponentProps) => ( + + + + + + + + {children} + + + + + + +); diff --git a/packages/widget/src/app/composition/providers/mount-animation/index.tsx b/packages/widget/src/app/composition/providers/mount-animation/index.tsx new file mode 100644 index 000000000..e2a5aece9 --- /dev/null +++ b/packages/widget/src/app/composition/providers/mount-animation/index.tsx @@ -0,0 +1,22 @@ +import { useAtomMount } from "@effect/atom-react"; +import { useEffect } from "react"; +import { useLocation } from "react-router"; +import { + mountAnimationCompletionAtom, + useMountAnimation, +} from "../../../../features/mount-animation/index"; + +export const MountAnimationEffects = () => { + const location = useLocation(); + const { dispatch } = useMountAnimation(); + + useAtomMount(mountAnimationCompletionAtom); + + useEffect(() => { + if (location.pathname !== "/") { + dispatch({ type: "all" }); + } + }, [location.pathname, dispatch]); + + return null; +}; diff --git a/packages/widget/src/app/composition/providers/query-client/index.tsx b/packages/widget/src/app/composition/providers/query-client/index.tsx new file mode 100644 index 000000000..6e023ca62 --- /dev/null +++ b/packages/widget/src/app/composition/providers/query-client/index.tsx @@ -0,0 +1,14 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; +import { useState } from "react"; + +/** Third-party infrastructure for Wagmi/RainbowKit; widget API state is forbidden here. */ +export const ThirdPartyQueryClientProvider = ({ + children, +}: PropsWithChildren) => { + const [queryClient] = useState(() => new QueryClient()); + + return ( + {children} + ); +}; diff --git a/packages/widget/src/app/composition/providers/rainbow-kit.tsx b/packages/widget/src/app/composition/providers/rainbow-kit.tsx new file mode 100644 index 000000000..eb4f5c29e --- /dev/null +++ b/packages/widget/src/app/composition/providers/rainbow-kit.tsx @@ -0,0 +1,133 @@ +import type { DisclaimerComponent } from "@stakekit/rainbowkit"; +import { + RainbowKitProvider, + useChainModal, + useConnectModal, +} from "@stakekit/rainbowkit"; +import type { PropsWithChildren } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useTrackEvent } from "../../../features/tracking/index"; +import { + addLedgerAccountAtom, + useLedgerDisabledChain, + useSKWallet, + walletModalAdapterAtom, +} from "../../../features/wallet/index"; +import { useWidgetConfig } from "../../../features/widget-configuration/index"; +import { shouldShowDisconnect } from "../../../services/wallet/wallet-connectors"; +import { vars } from "../../../shared/styles/theme/contract.css"; +import { id } from "../../../shared/styles/theme/ids"; +import type { ConnectKitTheme } from "../../../shared/styles/tokens/connect-kit"; +import { connectKitTheme } from "../../../shared/styles/tokens/connect-kit"; +import { Text } from "../../../shared/ui/primitives/typography/text"; + +const finalTheme: ConnectKitTheme = { + ...connectKitTheme.lightMode, + colors: vars.color.connectKit, // ThemeWrapper applies final light/dark colors + radii: vars.borderRadius.connectKit, + fonts: { body: vars.font.body }, +}; + +export const RainbowKitProviderWithTheme = ({ + children, +}: PropsWithChildren) => { + const wallet = useSKWallet(); + const connector = wallet?.network ? wallet.connector : null; + const connectorChains = wallet?.connectorChains ?? []; + + const portalContainer = useWidgetConfig("portalContainer"); + + const ledgerDisabledChains = useLedgerDisabledChain(); + + const trackEvent = useTrackEvent(); + + const addLedgerAccount = useAtomSet(addLedgerAccountAtom); + + const { t, i18n } = useTranslation(); + + const hideAccountAndChainSelector = useWidgetConfig( + "hideAccountAndChainSelector" + ); + const initialChain = useWidgetConfig("initialChain"); + + const chainIdsToUse = useMemo( + () => new Set(connectorChains.map((c) => c.id)), + [connectorChains] + ); + + const disabledChains = useMemo( + () => + ledgerDisabledChains.map((c) => ({ + ...c, + info: t("chain_modal.disabled_chain_info"), + })), + [ledgerDisabledChains, t] + ); + + const hideDisconnect = useMemo( + () => (connector ? !shouldShowDisconnect(connector) : true), + [connector] + ); + + const locale = useMemo(() => { + if (i18n.language === "fr" || i18n.language === "fr-FR") { + return i18n.language; + } + + return "en"; + }, [i18n.language]); + + return ( + { + trackEvent("addLedgerAccountClicked"); + addLedgerAccount({ chain: disabledChain }); + }} + locale={locale} + appInfo={{ disclaimer: Disclamer, appName: t("shared.stake_kit") }} + {...(hideAccountAndChainSelector && { avatar: null })} + showRecentTransactions={false} + initialChain={initialChain} + theme={finalTheme} + hideDisconnect={hideDisconnect} + dialogRoot={portalContainer} + > + + {children} + + ); +}; + +const WalletModalBoundary = () => { + const updateAdapter = useAtomSet(walletModalAdapterAtom); + const [owner] = useState(() => ({})); + const { closeChainModal } = useChainModal(); + const { openConnectModal } = useConnectModal(); + + useEffect(() => { + updateAdapter({ + _tag: "Install", + adapter: { + closeChain: closeChainModal, + openConnect: () => openConnectModal?.(), + }, + owner, + }); + return () => { + updateAdapter({ _tag: "Uninstall", owner }); + }; + }, [closeChainModal, openConnectModal, owner, updateAdapter]); + return null; +}; + +const Disclamer: DisclaimerComponent = () => { + const { t } = useTranslation(); + return {t("chain_modal_disclaimer")}; +}; + +import { useAtomSet } from "@effect/atom-react"; diff --git a/packages/widget/src/app/composition/providers/rainbow/account-identities.ts b/packages/widget/src/app/composition/providers/rainbow/account-identities.ts new file mode 100644 index 000000000..bbe2b5e90 --- /dev/null +++ b/packages/widget/src/app/composition/providers/rainbow/account-identities.ts @@ -0,0 +1,47 @@ +import { + isEvmWalletNetwork, + type WalletNetwork, +} from "../../../../domain/wallet/network"; + +type LedgerAccountIdentity = { + readonly address: string; +}; + +const isSameAccountAddress = ({ + currentAddress, + candidateAddress, + network, +}: { + readonly candidateAddress: string; + readonly currentAddress: string; + readonly network: WalletNetwork; +}) => + isEvmWalletNetwork(network) + ? candidateAddress.toLowerCase() === currentAddress.toLowerCase() + : candidateAddress === currentAddress; + +export const getOtherLedgerAccounts = ({ + accounts, + currentAddress, + network, +}: { + readonly accounts: ReadonlyArray; + readonly currentAddress: string; + readonly network: WalletNetwork; +}): ReadonlyArray => + accounts.filter( + (account) => + !isSameAccountAddress({ + candidateAddress: account.address, + currentAddress, + network, + }) + ); + +export const findLedgerAccountByAddress = < + Account extends LedgerAccountIdentity, +>( + accounts: ReadonlyArray, + selectedAddress: string +): Account | null => + accounts.find((account) => account.address === selectedAddress) ?? null; diff --git a/packages/widget/src/app/composition/providers/rainbow/index.tsx b/packages/widget/src/app/composition/providers/rainbow/index.tsx new file mode 100644 index 000000000..4e7329955 --- /dev/null +++ b/packages/widget/src/app/composition/providers/rainbow/index.tsx @@ -0,0 +1,60 @@ +import { useAtomSet } from "@effect/atom-react"; +import { AccountExtraInfoContext } from "@stakekit/rainbowkit"; +import type { PropsWithChildren } from "react"; +import type { Address } from "viem"; +import { useSKWallet } from "../../../../features/wallet/index"; +import type { WalletSwitchAccountInput } from "../../../../services/wallet/wallet-commands"; +import { WalletService } from "../../../../services/wallet/wallet-service"; +import { walletRuntime } from "../../../runtime/wallet-runtime"; +import { RainbowKitProviderWithTheme } from "../rainbow-kit"; +import { + findLedgerAccountByAddress, + getOtherLedgerAccounts, +} from "./account-identities"; + +const switchLedgerAccountAtom = walletRuntime.fn( + (input: WalletSwitchAccountInput) => + WalletService.use((wallet) => wallet.switchAccount(input)) +); + +export const RainbowProvider = ({ children }: PropsWithChildren) => { + const wallet = useSKWallet(); + const switchAccount = useAtomSet(switchLedgerAccountAtom, { + mode: "promise", + }); + + const otherAccounts = + wallet?.status === "connected" && wallet.ledgerAccounts + ? getOtherLedgerAccounts({ + accounts: wallet.ledgerAccounts, + currentAddress: wallet.address, + network: wallet.network, + }) + : []; + const otherAddresses = otherAccounts.map( + (account) => account.address as Address + ); + + return ( + { + const account = findLedgerAccountByAddress( + otherAccounts, + selectedAddress + ); + + if (account && wallet?.status === "connected") { + void switchAccount({ + account, + connector: wallet.connector, + }).catch(() => undefined); + } + }, + }} + > + {children} + + ); +}; diff --git a/packages/widget/src/app/composition/providers/theme-wrapper.tsx b/packages/widget/src/app/composition/providers/theme-wrapper.tsx new file mode 100644 index 000000000..61452d994 --- /dev/null +++ b/packages/widget/src/app/composition/providers/theme-wrapper.tsx @@ -0,0 +1,62 @@ +import { assignInlineVars } from "@vanilla-extract/dynamic"; +import type { PropsWithChildren } from "react"; +import { useMemo } from "react"; +import { useWidgetConfig } from "../../../features/widget-configuration/index"; +import type { WidgetConfig } from "../../../services/config/widget-config-model"; +import { mergeDeep } from "../../../shared/effect/merge-deep"; +import { vars } from "../../../shared/styles/theme/contract.css"; +import { rootSelector } from "../../../shared/styles/theme/ids"; +import { lightTheme } from "../../../shared/styles/theme/themes"; +import { getFineryThemeOverrides } from "../../../shared/styles/theme/variant-overrides/finery"; +import { portoThemeOverrides } from "../../../shared/styles/theme/variant-overrides/porto"; +import { utilaThemeOverrides } from "../../../shared/styles/theme/variant-overrides/utila"; +import type { RecursivePartial } from "../../../shared/types/utils"; + +export const getThemeOverrides = ({ + baseTheme, + variant, +}: { + baseTheme: typeof lightTheme; + variant: WidgetConfig["variant"]; +}): RecursivePartial => { + if (variant === "utila") { + return utilaThemeOverrides; + } + + if (variant === "finery") { + return getFineryThemeOverrides(baseTheme); + } + + if (variant === "porto") { + return portoThemeOverrides; + } + + return {}; +}; + +export const ThemeWrapper = ({ children }: PropsWithChildren) => { + const theme = useWidgetConfig("theme"); + const variant = useWidgetConfig("variant"); + + const finalTheme = useMemo(() => { + const baseTheme = mergeDeep(lightTheme, theme); + const overrides = getThemeOverrides({ + baseTheme, + variant, + }); + + return mergeDeep(lightTheme, theme, overrides); + }, [theme, variant]); + + return ( + <> + + + + ); + + const wrapper = app.container.querySelector( + '[data-rk="token-logo"]' + ); + const image = wrapper?.querySelector("img"); + + expect(wrapper?.getBoundingClientRect().width).toBe(24); + expect(wrapper?.getBoundingClientRect().height).toBe(24); + expect(image?.getBoundingClientRect().width).toBe(24); + expect(image?.getBoundingClientRect().height).toBe(24); + }); +}); diff --git a/packages/widget/tests/components/atoms/token-icon-image.dom.test.tsx b/packages/widget/tests/components/atoms/token-icon-image.dom.test.tsx new file mode 100644 index 000000000..ba801f2fd --- /dev/null +++ b/packages/widget/tests/components/atoms/token-icon-image.dom.test.tsx @@ -0,0 +1,46 @@ +import { act } from "react"; +import { describe, expect, it } from "vitest"; +import { TokenIconImage } from "../../../src/shared/ui/components/token-icon/token-icon-image"; +import { render } from "../../utils/test-utils.dom.tsx"; + +const validSrc = + "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='1'%20height='1'%3E%3C/svg%3E"; +const brokenSrcOne = "data:image/png;base64,dG9rZW4x"; + +describe("TokenIconImage", () => { + it("uses a single image element and prefers mainUrl when present", async () => { + const app = await render( + + ); + + expect(app.container.querySelectorAll("img")).toHaveLength(1); + + const image = app.container.querySelector("img"); + + expect(image).not.toBeNull(); + expect(image?.getAttribute("src")).toBe(validSrc); + }); + + it("falls through to the generated monogram after mainUrl fails", async () => { + const app = await render( + + ); + + const image = app.container.querySelector("img"); + + expect(image).not.toBeNull(); + act(() => image?.dispatchEvent(new Event("error"))); + + expect(image?.getAttribute("src")).toContain("data:image/svg+xml"); + }); +}); diff --git a/packages/widget/tests/components/atoms/token-icon-image.test.tsx b/packages/widget/tests/components/atoms/token-icon-image.test.tsx deleted file mode 100644 index ecfe01126..000000000 --- a/packages/widget/tests/components/atoms/token-icon-image.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { TokenIconImage } from "../../../src/components/atoms/token-icon/token-icon-image"; -import { describe, expect, it } from "../../utils/test-extend"; -import { render } from "../../utils/test-utils"; - -const validSrc = - "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='1'%20height='1'%3E%3C/svg%3E"; -const brokenSrcOne = "data:image/png;base64,dG9rZW4x"; - -describe("TokenIconImage", () => { - it("uses a single image element and prefers mainUrl when present", async () => { - const app = await render( - - ); - - expect(app.container.querySelectorAll("img")).toHaveLength(1); - - const image = app.container.querySelector("img"); - - expect(image).not.toBeNull(); - expect(image?.getAttribute("src")).toBe(validSrc); - }); - - it("sizes the image from a host-overridden wrapper", async () => { - const app = await render( -
- - -
- ); - - const wrapper = app.container.querySelector( - '[data-rk="token-logo"]' - ); - const image = wrapper?.querySelector("img"); - - expect(wrapper?.getBoundingClientRect().width).toBe(24); - expect(wrapper?.getBoundingClientRect().height).toBe(24); - expect(image?.getBoundingClientRect().width).toBe(24); - expect(image?.getBoundingClientRect().height).toBe(24); - }); - - it("falls through to the generated monogram after mainUrl fails", async () => { - const app = await render( - - ); - - const image = app.container.querySelector("img"); - - expect(image).not.toBeNull(); - image?.dispatchEvent(new Event("error")); - - expect(image?.getAttribute("src")).toContain("data:image/svg+xml"); - }); -}); diff --git a/packages/widget/tests/components/classic-exit-review-receive-token.browser.test.tsx b/packages/widget/tests/components/classic-exit-review-receive-token.browser.test.tsx new file mode 100644 index 000000000..c0198b975 --- /dev/null +++ b/packages/widget/tests/components/classic-exit-review-receive-token.browser.test.tsx @@ -0,0 +1,27 @@ +import { I18nextProvider } from "react-i18next"; +import { describe, expect, it } from "vitest"; +import ReviewTopSection from "../../src/features/classic-transaction-flow/ui/review/pages/common-page/components/review-top-section"; +import { createWidgetI18nInstance } from "../../src/services/translation/widget-translation"; +import { render } from "../utils/test-utils"; + +const i18nInstance = createWidgetI18nInstance(); + +describe("Classic Exit Review Receive Token", () => { + it("keeps the position amount and shows the selected receive token separately", async () => { + const app = await render( + + + + ); + + await expect.element(app.getByText("1 sUSDS")).toBeInTheDocument(); + await expect.element(app.getByText("Receive token")).toBeInTheDocument(); + await expect.element(app.getByText("USDC")).toBeInTheDocument(); + }); +}); diff --git a/packages/widget/tests/components/dashboard-header.browser.test.tsx b/packages/widget/tests/components/dashboard-header.browser.test.tsx new file mode 100644 index 000000000..39822079e --- /dev/null +++ b/packages/widget/tests/components/dashboard-header.browser.test.tsx @@ -0,0 +1,67 @@ +import { expect, it, vi } from "vitest"; +import { render } from "../utils/test-utils"; + +vi.mock("@stakekit/rainbowkit", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + ConnectButton: { + Custom: ({ + children, + }: { + children: (state: object) => React.ReactNode; + }) => + children({ + account: { address: "0x1234" }, + chain: { id: 1 }, + mounted: true, + }), + }, + }; +}); + +vi.mock("../../src/features/wallet/views", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + AccountModal: () =>
Account
, + ChainModal: () =>
Chain
, + }; +}); + +vi.mock("../../src/features/widget-shell/header/use-header", () => ({ + useHeader: () => ({ + containerRef: { current: null }, + hideAccountAndChainSelector: false, + hideChainSelector: false, + isConnected: true, + isConnecting: false, + onXPress: vi.fn(), + showDisconnect: true, + walletConfigReady: true, + }), +})); + +import { Header } from "../../src/features/widget-shell/dashboard/components/header"; + +it("scopes the dashboard disconnect hit target to the icon", async () => { + const app = await render( +
+
+
+ ); + const disconnectButton = app.container.querySelector("button"); + const disconnectIcon = disconnectButton?.querySelector("svg"); + + expect(disconnectButton).not.toBeNull(); + expect(disconnectIcon?.getBoundingClientRect()).toMatchObject({ + height: 24, + width: 24, + }); + expect(disconnectButton?.getBoundingClientRect()).toMatchObject({ + height: 24, + width: 24, + }); +}); diff --git a/packages/widget/tests/components/help-modal-skapp-children.dom.test.tsx b/packages/widget/tests/components/help-modal-skapp-children.dom.test.tsx new file mode 100644 index 000000000..5048dedc9 --- /dev/null +++ b/packages/widget/tests/components/help-modal-skapp-children.dom.test.tsx @@ -0,0 +1,83 @@ +import { RegistryProvider } from "@effect/atom-react"; +import { Layer } from "effect"; +import { act } from "react"; +import { describe, expect, it } from "vitest"; +import { appRuntime } from "../../src/app/runtime/app-runtime"; +import { HelpModal } from "../../src/features/preferences/views"; +import { + composeWidgetTranslationResources, + createWidgetI18nInstance, + reconcileWidgetI18n, + WidgetTranslation, +} from "../../src/services/translation/widget-translation"; +import { render } from "../utils/test-utils.dom.tsx"; + +const makeI18n = () => { + const i18n = createWidgetI18nInstance(); + reconcileWidgetI18n({ + i18n, + language: "en", + resources: composeWidgetTranslationResources({ + apiErrors: undefined, + customTranslations: undefined, + language: "en", + variant: "default", + }), + }); + return i18n; +}; + +/** + * Mirrors SKApp children: host chrome beside HelpModal under one registry, + * without wrapping the footer in WidgetTranslationGate. + */ +describe("HelpModal as SKApp children", () => { + it("translates the modal while sibling host chrome stays mounted", async () => { + const i18n = makeI18n(); + const expectedTitle = i18n.t("help_modals.what_is_stakekit.title"); + const app = await render( + +
frame
+
+ Terms & Conditions + +
+
+ ); + + expect( + app.container.querySelector('[data-testid="footer-link"]')?.textContent + ).toBe("Terms & Conditions"); + + await expect + .poll(() => app.container.querySelector("button")) + .not.toBeNull(); + + await act(async () => { + app.container.querySelector("button")?.click(); + }); + + await expect + .poll( + () => document.body.querySelector("[data-select-modal] h4")?.textContent + ) + .toBe(expectedTitle); + + expect(expectedTitle).not.toBe("help_modals.what_is_stakekit.title"); + expect( + app.container.querySelector('[data-testid="footer-link"]')?.textContent + ).toBe("Terms & Conditions"); + + app.unmount(); + }); +}); diff --git a/packages/widget/tests/components/number-input-locale.test.ts b/packages/widget/tests/components/number-input-locale.test.ts new file mode 100644 index 000000000..324e2018f --- /dev/null +++ b/packages/widget/tests/components/number-input-locale.test.ts @@ -0,0 +1,56 @@ +import BigNumber from "bignumber.js"; +import { describe, expect, it } from "vitest"; +import { + formatLocalizedNumber, + isLocalizedNumberInput, + parseLocalizedNumberInput, +} from "../../src/shared/ui/components/number-input/locale-number"; + +describe("localized number input", () => { + it("uses English decimal and grouping separators without guessing", () => { + expect(parseLocalizedNumberInput("0.123", "en")?.toFixed()).toBe("0.123"); + expect(parseLocalizedNumberInput("1,234.5", "en")?.toFixed()).toBe( + "1234.5" + ); + expect(isLocalizedNumberInput("0,123", "en")).toBe(false); + expect(parseLocalizedNumberInput("0,123", "en")).toBeNull(); + }); + + it("uses French decimal and whitespace grouping separators", () => { + expect(parseLocalizedNumberInput("0,123", "fr")?.toFixed()).toBe("0.123"); + expect(parseLocalizedNumberInput("1 234,5", "fr")?.toFixed()).toBe( + "1234.5" + ); + expect(parseLocalizedNumberInput("1 234,5", "fr")?.toFixed()).toBe( + "1234.5" + ); + expect(isLocalizedNumberInput("1,234.5", "fr")).toBe(false); + }); + + it("formats grouped display and ungrouped editing values for each locale", () => { + const value = new BigNumber("1234.5"); + + expect( + formatLocalizedNumber({ locale: "en", useGrouping: true, value }) + ).toBe("1,234.5"); + expect( + formatLocalizedNumber({ locale: "fr", useGrouping: true, value }) + ).toBe("1 234,5"); + expect( + formatLocalizedNumber({ locale: "fr", useGrouping: false, value }) + ).toBe("1234,5"); + }); + + it("round-trips values without converting through JavaScript numbers", () => { + const value = new BigNumber("9007199254740993.000000000000000001"); + const formatted = formatLocalizedNumber({ + locale: "fr", + useGrouping: true, + value, + }); + + expect(parseLocalizedNumberInput(formatted, "fr")?.isEqualTo(value)).toBe( + true + ); + }); +}); diff --git a/packages/widget/tests/components/number-input.dom.test.tsx b/packages/widget/tests/components/number-input.dom.test.tsx new file mode 100644 index 000000000..e9805885a --- /dev/null +++ b/packages/widget/tests/components/number-input.dom.test.tsx @@ -0,0 +1,67 @@ +import BigNumber from "bignumber.js"; +import { act, useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { NumberInput } from "../../src/shared/ui/components/number-input"; +import { + type WidgetPresentation, + WidgetPresentationProvider, +} from "../../src/shared/ui/widget-presentation"; +import { render } from "../utils/test-utils.dom.tsx"; + +const presentation = (locale: string): WidgetPresentation => ({ + disableInputAutoResize: true, + hideNetworkLogo: false, + locale, + mapNetworkIconUrl: () => undefined, + mapTokenIconUrl: () => undefined, + portalContainer: undefined, + variant: "default", +}); + +const setNativeInputValue = (input: HTMLInputElement, value: string) => { + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + )?.set; + if (!setValue) throw new Error("Expected native input setter"); + + setValue.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +}; + +describe("NumberInput locale integration", () => { + it("uses the presentation locale for editing, parsing, and blur formatting", async () => { + const onChange = vi.fn(); + const Harness = () => { + const [value, setValue] = useState(new BigNumber(0)); + + return ( + + { + onChange(next); + setValue(next); + }} + /> + + ); + }; + const app = await render(); + const input = app.container.querySelector("input"); + if (!input) throw new Error("Expected number input"); + expect(input.parentElement?.querySelector("span")).not.toBeNull(); + + await act(async () => input.focus()); + await act(async () => setNativeInputValue(input, "1234,5")); + + expect(onChange.mock.lastCall?.[0].toFixed()).toBe("1234.5"); + expect(input.value).toBe("1234,5"); + + await act(async () => input.blur()); + expect(input.value).toBe("1 234,5"); + + await act(async () => input.focus()); + expect(input.value).toBe("1234,5"); + }); +}); diff --git a/packages/widget/tests/components/position-details-exit-receive-token.browser.test.tsx b/packages/widget/tests/components/position-details-exit-receive-token.browser.test.tsx new file mode 100644 index 000000000..2d1b30e79 --- /dev/null +++ b/packages/widget/tests/components/position-details-exit-receive-token.browser.test.tsx @@ -0,0 +1,116 @@ +import { Schema } from "effect"; +import { useState } from "react"; +import { I18nextProvider } from "react-i18next"; +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; +import type { ExitReceiveToken } from "../../src/domain/action/rules"; +import { TokenAddress } from "../../src/domain/identity/identifiers"; +import type { Token } from "../../src/domain/token/token"; +import { ExitReceiveTokenAccessory } from "../../src/features/position-details/ui/classic/components/exit-receive-token-accessory"; +import { ExitReceiveTokenNote } from "../../src/features/position-details/ui/classic/components/exit-receive-token-note"; +import { createWidgetI18nInstance } from "../../src/services/translation/widget-translation"; +import { yieldApiYieldFixture } from "../fixtures"; +import { render } from "../utils/test-utils"; +import { TestWidgetConfigProvider } from "../utils/widget-config-provider"; + +const i18nInstance = createWidgetI18nInstance(); +const usdsAddress = Schema.decodeSync(TokenAddress)( + "0xdC035D45d973E3EC169d2276DDab16f1e407384F" +); +const usdcAddress = Schema.decodeSync(TokenAddress)( + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" +); + +const options: ReadonlyArray = [ + { address: usdsAddress, symbol: "USDS" }, + { address: usdcAddress, symbol: "USDC" }, +]; + +const usds: Token = { + ...yieldApiYieldFixture().token, + address: usdsAddress, + name: "USDS", + symbol: "USDS", +}; +const usdc: Token = { + ...yieldApiYieldFixture().token, + address: usdcAddress, + name: "USD Coin", + symbol: "USDC", +}; + +const integration = yieldApiYieldFixture({ + inputTokens: [usds, usdc], + providerId: "sky", + token: usds, + tokens: [usds], + outputToken: { + ...yieldApiYieldFixture().token, + address: Schema.decodeSync(TokenAddress)( + "0x3333333333333333333333333333333333333333" + ), + name: "Savings USDS", + symbol: "sUSDS", + }, +}); + +const Harness = () => { + const [selected, setSelected] = useState(options[0]!); + const selection = { options, selected }; + + return ( + <> + { + const option = options.find( + (candidate) => candidate.address === address + ); + if (option) setSelected(option); + }} + positionToken={usds} + selection={selection} + /> + + + ); +}; + +describe("Position Details Exit Receive Token selector", () => { + it("opens a receive-token modal and updates the chip plus note", async () => { + const app = await render( + + + + + + ); + + await expect.element(app.getByText("USDS")).toBeInTheDocument(); + await expect + .element(app.getByTestId("position-details-exit-receive-token-note")) + .not.toBeInTheDocument(); + + await userEvent.click( + app.getByTestId("position-details-exit-receive-token") + ); + await expect + .element(app.getByTestId("select-modal__title")) + .toHaveTextContent("Select receive token"); + await expect.element(app.getByText("0xa0b8\u2026eb48")).toBeInTheDocument(); + + await userEvent.click(app.getByText("USDC")); + + await expect + .element(app.getByTestId("position-details-exit-receive-token")) + .toHaveTextContent("USDC"); + await expect + .element(app.getByText("You will receive USDC when you withdraw.")) + .toBeInTheDocument(); + }); +}); diff --git a/packages/widget/tests/components/position-source-details.browser.test.tsx b/packages/widget/tests/components/position-source-details.browser.test.tsx new file mode 100644 index 000000000..587f0abce --- /dev/null +++ b/packages/widget/tests/components/position-source-details.browser.test.tsx @@ -0,0 +1,132 @@ +import { I18nextProvider } from "react-i18next"; +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; +import type { EarnYieldWithProvider } from "../../src/domain/earn/models"; +import { PositionSourceDetails } from "../../src/features/position-details/ui/classic/components/position-source-details"; +import { YieldDetails } from "../../src/features/position-details/ui/classic/components/yield-details"; +import { createWidgetI18nInstance } from "../../src/services/translation/widget-translation"; +import { + yieldApiProviderFixture, + yieldApiYieldFixture, + yieldRewardRateFixture, +} from "../fixtures"; +import { render } from "../utils/test-utils"; + +const i18nInstance = createWidgetI18nInstance(); +const baseYield = yieldApiYieldFixture(); +const integration: EarnYieldWithProvider = { + ...baseYield, + mechanics: { + ...baseYield.mechanics, + lockupPeriod: { seconds: 7 * 24 * 60 * 60 }, + rewardClaiming: "auto", + rewardSchedule: "day", + type: "vault", + warmupPeriod: { seconds: 3 * 24 * 60 * 60 }, + }, + provider: yieldApiProviderFixture({ + name: "Aave", + website: "https://www.aave.com", + }), + rewardRate: yieldRewardRateFixture({ total: 0.04 }), + state: { + pricePerShareState: { + price: 1.06274537, + quoteToken: baseYield.token, + shareToken: baseYield.token, + }, + }, +}; + +describe("Position source yield details", () => { + it("reveals useful non-validator yield facts without repeating the visible rate", async () => { + const app = await render( + + + + ); + + await expect + .element(app.getByText("Deposited via Aave")) + .toBeInTheDocument(); + await expect + .element(app.getByText("Reward claiming")) + .not.toBeInTheDocument(); + + await userEvent.click(app.getByTestId("position-source-details-trigger")); + + await expect.element(app.getByText("aave.com")).toBeInTheDocument(); + await expect.element(app.getByText("Reward claiming")).toBeInTheDocument(); + await expect.element(app.getByText("Auto-compounding")).toBeInTheDocument(); + await expect.element(app.getByText("Reward schedule")).toBeInTheDocument(); + await expect + .element(app.getByText("Day", { exact: true })) + .toBeInTheDocument(); + await expect.element(app.getByText("Warmup")).toBeInTheDocument(); + await expect.element(app.getByText("3 days")).toBeInTheDocument(); + await expect.element(app.getByText("Lockup")).toBeInTheDocument(); + await expect.element(app.getByText("7 days")).toBeInTheDocument(); + await expect.element(app.getByText("Price per share")).toBeInTheDocument(); + await expect.element(app.getByText("1.06274537")).toBeInTheDocument(); + await expect.element(app.getByText("APY (7D)")).not.toBeInTheDocument(); + }); + + it("shows the yield rate when it is not presented above the accordion", async () => { + const app = await render( + + + + ); + + await userEvent.click(app.getByTestId("position-source-details-trigger")); + + await expect.element(app.getByText("APY (7D)")).toBeInTheDocument(); + await expect.element(app.getByText("4%")).toBeInTheDocument(); + }); + + it("prioritizes fees over other optional facts", async () => { + const app = await render( + + + + ); + + await userEvent.click(app.getByTestId("position-source-details-trigger")); + + await expect.element(app.getByText("Fees")).toBeInTheDocument(); + await expect.element(app.getByText("2%")).toBeInTheDocument(); + await expect + .element(app.getByText("Price per share")) + .not.toBeInTheDocument(); + }); + + it("does not offer disclosure when there are no detail rows", async () => { + const app = await render( + + + + ); + + await expect + .element(app.getByText("Deposited via Aave")) + .toBeInTheDocument(); + await expect + .element(app.getByTestId("position-source-details-trigger")) + .not.toBeInTheDocument(); + }); +}); diff --git a/packages/widget/tests/components/rich-error-modal.dom.test.tsx b/packages/widget/tests/components/rich-error-modal.dom.test.tsx new file mode 100644 index 000000000..cc61c4f93 --- /dev/null +++ b/packages/widget/tests/components/rich-error-modal.dom.test.tsx @@ -0,0 +1,288 @@ +import type { ReactNode } from "react"; +import { I18nextProvider } from "react-i18next"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RichError } from "../../src/services/errors/rich-error"; +import { createWidgetI18nInstance } from "../../src/services/translation/widget-translation"; +import { render } from "../utils/test-utils.dom"; + +const richErrorState = vi.hoisted( + (): { + error: RichError | null; + resetError: ReturnType; + } => ({ + error: null, + resetError: vi.fn(), + }) +); + +vi.mock("../../src/features/widget-shell/react/use-rich-errors", () => ({ + useRichErrors: () => richErrorState, +})); + +vi.mock("../../src/shared/ui/components/select-modal", () => ({ + SelectModal: ({ + children, + state, + }: { + children: ReactNode; + state: { isOpen: boolean }; + }) => (state.isOpen ? children : null), +})); + +import { RichErrorModal } from "../../src/features/widget-shell/ui/rich-error-modal"; + +const renderModal = () => + render( + + + + ); + +describe("RichErrorModal", () => { + afterEach(() => { + richErrorState.error = null; + richErrorState.resetError.mockClear(); + }); + + it("renders English Error Copy details instead of the API reason", async () => { + richErrorState.error = { + message: "KaminoLendingInsufficientSolForRentError", + details: { + reason: + "Insufficient SOL for transaction. Required: ~0.005 SOL, Current: 0.004 SOL", + }, + }; + + await renderModal(); + + expect(document.body.textContent).toContain( + "Insufficient SOL for Account Rent" + ); + expect(document.body.textContent).toContain( + "There is not enough SOL to fund the account rent required by Kamino Lending" + ); + expect(document.body.textContent).toContain("Potential solution:"); + expect(document.body.textContent).toContain( + "Add SOL to the account and try again" + ); + expect(document.body.textContent).not.toContain( + "Insufficient SOL for transaction. Required: ~0.005 SOL, Current: 0.004 SOL" + ); + expect(document.body.textContent).not.toContain( + "KaminoLendingInsufficientSolForRentError" + ); + }); + + it("keeps French Error Copy details instead of the English API reason", async () => { + const i18n = createWidgetI18nInstance(); + await i18n.changeLanguage("fr"); + richErrorState.error = { + message: "KaminoLendingInsufficientSolForRentError", + details: { + reason: + "Insufficient SOL for transaction. Required: ~0.005 SOL, Current: 0.004 SOL", + }, + }; + + await render( + + + + ); + + expect(document.body.textContent).toContain( + "SOL insuffisant pour le loyer du compte" + ); + expect(document.body.textContent).toContain( + "Le solde SOL est insuffisant pour financer le loyer de compte requis par Kamino Lending" + ); + expect(document.body.textContent).not.toContain( + "Insufficient SOL for transaction" + ); + }); + + it("renders host Error Copy details instead of the API reason", async () => { + const i18n = createWidgetI18nInstance(); + i18n.addResourceBundle( + "en", + "translation", + { + errors: { + KaminoLendingInsufficientSolForRentError: { details: "HOST DETAILS" }, + }, + }, + true, + true + ); + richErrorState.error = { + message: "KaminoLendingInsufficientSolForRentError", + details: { reason: "Insufficient SOL for transaction." }, + }; + + await render( + + + + ); + + expect(document.body.textContent).toContain("HOST DETAILS"); + expect(document.body.textContent).not.toContain( + "Insufficient SOL for transaction." + ); + expect(document.body.textContent).toContain( + "Add SOL to the account and try again" + ); + }); + + it("interpolates values explicitly referenced by host Error Copy", async () => { + const i18n = createWidgetI18nInstance(); + i18n.addResourceBundle( + "en", + "translation", + { + errors: { + KaminoLendingInsufficientSolForRentError: { + details: "The account needs {{amount}} for rent", + }, + }, + }, + true, + true + ); + richErrorState.error = { + message: "KaminoLendingInsufficientSolForRentError", + details: { + amount: "0.005 SOL", + reason: "Internal provider payload", + }, + }; + + await render( + + + + ); + + expect(document.body.textContent).toContain( + "The account needs 0.005 SOL for rent" + ); + expect(document.body.textContent).not.toContain( + "Internal provider payload" + ); + }); + + it("does not interpolate reason when host Error Copy references it", async () => { + const i18n = createWidgetI18nInstance(); + i18n.addResourceBundle( + "en", + "translation", + { + errors: { + KaminoLendingInsufficientSolForRentError: { + details: "Provider reason: {{reason}}", + }, + }, + }, + true, + true + ); + richErrorState.error = { + message: "KaminoLendingInsufficientSolForRentError", + details: { reason: "Internal provider payload" }, + }; + + await render( + + + + ); + + expect(document.body.textContent).not.toContain( + "Internal provider payload" + ); + }); + + it("renders only the generic French title for an unknown identity", async () => { + const i18n = createWidgetI18nInstance(); + await i18n.changeLanguage("fr"); + richErrorState.error = { + message: "FutureApiError", + details: { reason: "The operation is temporarily unavailable." }, + }; + + await render( + + + + ); + + expect(document.body.textContent?.trim()).toBe("Une erreur s'est produite"); + }); + + it("renders only the generic title for an unknown English identity", async () => { + richErrorState.error = { + message: "FutureApiError", + details: { reason: "The operation is temporarily unavailable." }, + }; + await renderModal(); + + expect(document.body.textContent?.trim()).toBe("Something went wrong"); + expect(document.body.textContent).not.toContain( + "The operation is temporarily unavailable." + ); + expect(document.body.textContent).not.toContain("FutureApiError"); + }); + + it("treats incomplete catalog copy as unknown and hides the identity", async () => { + const i18n = createWidgetI18nInstance(); + i18n.addResourceBundle( + "en", + "translation", + { errors: { FutureApiError: { title: "Only a title" } } }, + true, + true + ); + richErrorState.error = { + message: "FutureApiError", + details: { reason: "The operation is temporarily unavailable." }, + }; + await render( + + + + ); + + expect(document.body.textContent).toContain("Something went wrong"); + expect(document.body.textContent).not.toContain( + "The operation is temporarily unavailable." + ); + expect(document.body.textContent).not.toContain("FutureApiError"); + expect(document.body.textContent).not.toContain("Only a title"); + expect(document.body.textContent).not.toContain( + "errors.FutureApiError.details" + ); + }); + + it("treats a prose-shaped message as an unknown identity", async () => { + richErrorState.error = { message: "KYC required" }; + + await renderModal(); + + expect(document.body.textContent).toContain("Something went wrong"); + expect(document.body.textContent).not.toContain("KYC required"); + expect(document.body.textContent?.trim()).toBe("Something went wrong"); + }); + + it("renders only the generic title when an unknown error has no usable reason", async () => { + richErrorState.error = { + message: "FutureApiError", + details: { reason: " " }, + }; + + await renderModal(); + + expect(document.body.textContent).toContain("Something went wrong"); + expect(document.body.textContent).not.toContain("FutureApiError"); + expect(document.body.textContent?.trim()).toBe("Something went wrong"); + }); +}); diff --git a/packages/widget/tests/components/select-validator-section.browser.test.tsx b/packages/widget/tests/components/select-validator-section.browser.test.tsx new file mode 100644 index 000000000..400f5349b --- /dev/null +++ b/packages/widget/tests/components/select-validator-section.browser.test.tsx @@ -0,0 +1,192 @@ +import { I18nextProvider } from "react-i18next"; +import { describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; +import type { EarnYieldWithProvider } from "../../src/domain/earn/models"; +import { SelectValidatorSection } from "../../src/features/earn/ui/classic/earn-page/components/select-validator-section"; +import type { useSelectValidator } from "../../src/features/earn/ui/classic/earn-page/components/select-validator-section/use-select-validator"; +import { createWidgetI18nInstance } from "../../src/services/translation/widget-translation"; +import { yieldApiValidatorFixture, yieldApiYieldFixture } from "../fixtures"; +import { render } from "../utils/test-utils"; +import { decodeValidator } from "../utils/validators"; +import { TestWidgetConfigProvider } from "../utils/widget-config-provider"; + +const i18nInstance = createWidgetI18nInstance(); + +const hookState = vi.hoisted(() => ({ + current: undefined as unknown as ReturnType, +})); + +vi.mock( + "../../src/features/earn/ui/classic/earn-page/components/select-validator-section/use-select-validator", + () => ({ + useSelectValidator: () => hookState.current, + }) +); + +const baseYield = yieldApiYieldFixture(); +const selectedStake = { + ...baseYield, + mechanics: { + ...baseYield.mechanics, + requiresValidatorSelection: true, + arguments: { + enter: { + fields: { + validatorAddress: { + required: true, + }, + }, + }, + exit: { fields: {} }, + }, + }, +} satisfies EarnYieldWithProvider; + +const multiSelectStake = { + ...selectedStake, + mechanics: { + ...selectedStake.mechanics, + arguments: { + ...selectedStake.mechanics.arguments, + enter: { + fields: { + validatorAddresses: { + required: true, + }, + }, + }, + }, + }, +} satisfies EarnYieldWithProvider; + +const createHookValue = ( + overrides: Partial> = {} +): ReturnType => ({ + isLoading: false, + onViewMoreClick: vi.fn(), + onClose: vi.fn(), + onOpen: vi.fn(), + onItemClick: vi.fn(), + onRemoveValidator: vi.fn(), + selectedValidators: new Map(), + selectedStake, + onValidatorSearch: vi.fn(), + validatorsData: [decodeValidator(yieldApiValidatorFixture())], + validatorSearch: "", + hasMoreValidators: false, + isLoadingMoreValidators: false, + onLoadMoreValidators: vi.fn(), + ...overrides, +}); + +const renderSection = () => + render( + + + + + + ); + +describe("SelectValidatorSection", () => { + it("keeps validator selection available when search has no results", async () => { + hookState.current = createHookValue({ + isLoading: false, + validatorsData: [], + validatorSearch: "missing validator", + }); + + const app = await renderSection(); + + await expect.element(app.getByText("Select validator")).toBeInTheDocument(); + + const trigger = app.container.querySelector("button"); + expect(trigger).not.toBeNull(); + + await userEvent.click(trigger as HTMLButtonElement); + + await expect + .element(app.getByTestId("select-modal__search-input")) + .toHaveValue("missing validator"); + await expect + .element(app.getByText("No validators found")) + .toBeInTheDocument(); + }); + + it("keeps validator selection available while initial validators load", async () => { + hookState.current = createHookValue({ + isLoading: true, + validatorsData: null, + validatorSearch: "", + }); + + const app = await renderSection(); + + await expect.element(app.getByText("Select validator")).toBeInTheDocument(); + + const trigger = app.container.querySelector("button"); + expect(trigger).not.toBeNull(); + + await userEvent.click(trigger as HTMLButtonElement); + + await expect + .element(app.getByTestId("select-modal__search-input")) + .toBeInTheDocument(); + await expect + .element(app.getByText("No validators available")) + .not.toBeInTheDocument(); + }); + + it("shows an empty state in the dialog when no validators are available", async () => { + hookState.current = createHookValue({ + isLoading: false, + validatorsData: [], + validatorSearch: "", + }); + + const app = await renderSection(); + + const trigger = app.container.querySelector("button"); + expect(trigger).not.toBeNull(); + + await userEvent.click(trigger as HTMLButtonElement); + + await expect + .element(app.getByText("No validators available")) + .toBeInTheDocument(); + }); + + it("shows the View all action for multi-select validators when more pages exist", async () => { + hookState.current = createHookValue({ + selectedStake: multiSelectStake, + validatorsData: Array.from({ length: 4 }, (_, index) => + decodeValidator( + yieldApiValidatorFixture({ + address: `validator-${index}`, + name: `Validator ${index}`, + preferred: true, + }) + ) + ), + hasMoreValidators: true, + }); + + const app = await renderSection(); + + await expect.element(app.getByText("Earn with")).toBeInTheDocument(); + + const trigger = app.container.querySelector( + '[data-rk="select-validator-plus"]' + ); + expect(trigger).not.toBeNull(); + + await userEvent.click(trigger as HTMLButtonElement); + + await expect.element(app.getByText("View all")).toBeInTheDocument(); + }); +}); diff --git a/packages/widget/tests/components/select-validator-section.test.tsx b/packages/widget/tests/components/select-validator-section.test.tsx deleted file mode 100644 index a3f78c176..000000000 --- a/packages/widget/tests/components/select-validator-section.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { Maybe } from "purify-ts"; -import { I18nextProvider } from "react-i18next"; -import { vi } from "vitest"; -import { userEvent } from "vitest/browser"; -import type { Yield } from "../../src/domain/types/yields"; -import { SelectValidatorSection } from "../../src/pages/details/earn-page/components/select-validator-section"; -import type { useSelectValidator } from "../../src/pages/details/earn-page/components/select-validator-section/use-select-validator"; -import { SettingsContextProvider } from "../../src/providers/settings"; -import { i18nInstance } from "../../src/translation"; -import { yieldApiValidatorFixture, yieldApiYieldFixture } from "../fixtures"; -import { describe, expect, it } from "../utils/test-extend"; -import { render } from "../utils/test-utils"; - -const hookState = vi.hoisted(() => ({ - current: undefined as unknown as ReturnType, -})); - -vi.mock( - "../../src/pages/details/earn-page/components/select-validator-section/use-select-validator", - () => ({ - useSelectValidator: () => hookState.current, - }) -); - -const baseYield = yieldApiYieldFixture(); -const selectedStake = { - ...baseYield, - mechanics: { - ...baseYield.mechanics, - requiresValidatorSelection: true, - arguments: { - enter: { - fields: [ - { - label: "Validator", - name: "validatorAddress", - required: true, - type: "address", - }, - ], - }, - exit: { fields: [] }, - }, - }, -} as Yield; - -const multiSelectStake = { - ...selectedStake, - mechanics: { - ...selectedStake.mechanics, - arguments: { - ...selectedStake.mechanics.arguments, - enter: { - fields: [ - { - label: "Validators", - name: "validatorAddresses", - required: true, - type: "address", - }, - ], - }, - }, - }, -} as Yield; - -const createHookValue = ( - overrides: Partial> = {} -): ReturnType => ({ - isLoading: false, - onViewMoreClick: vi.fn(), - onClose: vi.fn(), - onOpen: vi.fn(), - onItemClick: vi.fn(), - onRemoveValidator: vi.fn(), - selectedValidators: new Map(), - selectedStake: Maybe.of(selectedStake), - onValidatorSearch: vi.fn(), - validatorsData: Maybe.of([yieldApiValidatorFixture()]), - validatorSearch: "", - hasMoreValidators: false, - isLoadingMoreValidators: false, - onLoadMoreValidators: vi.fn(), - ...overrides, -}); - -const renderSection = () => - render( - - - - - - ); - -describe("SelectValidatorSection", () => { - it("keeps validator selection available when search has no results", async () => { - hookState.current = createHookValue({ - isLoading: false, - validatorsData: Maybe.of([]), - validatorSearch: "missing validator", - }); - - const app = await renderSection(); - - await expect.element(app.getByText("Select validator")).toBeInTheDocument(); - - const trigger = app.container.querySelector("button"); - expect(trigger).not.toBeNull(); - - await userEvent.click(trigger as HTMLButtonElement); - - await expect - .element(app.getByTestId("select-modal__search-input")) - .toHaveValue("missing validator"); - await expect - .element(app.getByText("No validators found")) - .toBeInTheDocument(); - }); - - it("keeps validator selection available while initial validators load", async () => { - hookState.current = createHookValue({ - isLoading: true, - validatorsData: Maybe.empty(), - validatorSearch: "", - }); - - const app = await renderSection(); - - await expect.element(app.getByText("Select validator")).toBeInTheDocument(); - - const trigger = app.container.querySelector("button"); - expect(trigger).not.toBeNull(); - - await userEvent.click(trigger as HTMLButtonElement); - - await expect - .element(app.getByTestId("select-modal__search-input")) - .toBeInTheDocument(); - await expect - .element(app.getByText("No validators available")) - .not.toBeInTheDocument(); - }); - - it("shows an empty state in the dialog when no validators are available", async () => { - hookState.current = createHookValue({ - isLoading: false, - validatorsData: Maybe.of([]), - validatorSearch: "", - }); - - const app = await renderSection(); - - const trigger = app.container.querySelector("button"); - expect(trigger).not.toBeNull(); - - await userEvent.click(trigger as HTMLButtonElement); - - await expect - .element(app.getByText("No validators available")) - .toBeInTheDocument(); - }); - - it("shows the View all action for multi-select validators when more pages exist", async () => { - hookState.current = createHookValue({ - selectedStake: Maybe.of(multiSelectStake), - validatorsData: Maybe.of( - Array.from({ length: 4 }, (_, index) => - yieldApiValidatorFixture({ - address: `validator-${index}`, - name: `Validator ${index}`, - preferred: true, - }) - ) - ), - hasMoreValidators: true, - }); - - const app = await renderSection(); - - await expect.element(app.getByText("Earn with")).toBeInTheDocument(); - - const trigger = app.container.querySelector( - '[data-rk="select-validator-plus"]' - ); - expect(trigger).not.toBeNull(); - - await userEvent.click(trigger as HTMLButtonElement); - - await expect.element(app.getByText("View all")).toBeInTheDocument(); - }); -}); diff --git a/packages/widget/tests/domain/action-command-truncation.test.ts b/packages/widget/tests/domain/action-command-truncation.test.ts new file mode 100644 index 000000000..160aa7a67 --- /dev/null +++ b/packages/widget/tests/domain/action-command-truncation.test.ts @@ -0,0 +1,381 @@ +import BigNumber from "bignumber.js"; +import { Result, Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { + getPendingActionStateKey, + preparePendingActionCommand, +} from "../../src/domain/action/action-command"; +import { YieldAction } from "../../src/domain/action/models"; +import { EarnBalance } from "../../src/domain/earn/models"; +import { WalletAddress } from "../../src/domain/identity/identifiers"; +import { projectYieldEntry } from "../../src/features/yield-entry/model/yield-entry"; +import { defaultFormattedNumber } from "../../src/shared/lib/number-format"; +import { + yieldApiActionDtoFixture, + yieldApiYieldDtoFixture, + yieldApiYieldFixture, + yieldBalanceFixture, +} from "../fixtures"; + +const address = Schema.decodeSync(WalletAddress)( + "0x1234567890123456789012345678901234567890" +); + +describe("executable token amount truncation", () => { + it("decodes Yield Action amounts from number and string wire forms", () => { + const fromNumber = Schema.decodeSync(YieldAction)( + yieldApiActionDtoFixture({ + amount: 1.5, + amountRaw: 1_500_000, + amountUsd: 3, + }) + ); + const fromString = Schema.decodeSync(YieldAction)( + yieldApiActionDtoFixture({ + amount: "1.5", + amountRaw: "1500000", + amountUsd: "3", + }) + ); + + expect(fromNumber.amount?.isEqualTo(fromString.amount ?? 0)).toBe(true); + expect(fromNumber.amountRaw).toBe(fromString.amountRaw); + expect(fromNumber.amountUsd?.isEqualTo(fromString.amountUsd ?? 0)).toBe( + true + ); + }); + + it("keeps Enter amounts already within token precision", () => { + const selectedYield = yieldApiYieldFixture(); + const view = projectYieldEntry({ + input: { + additionalValidationErrors: undefined, + amountInitialization: "PreserveIntent", + availableAmount: new BigNumber("1.5"), + connected: true, + entry: { + amount: new BigNumber("1.5"), + selectedProviderYieldId: null, + token: { ...selectedYield.token, decimals: 6 }, + tronResource: null, + useMaxAmount: false, + validators: new Map(), + yield: selectedYield, + }, + externalProviders: false, + hasNoYields: false, + isKycBlocking: false, + isKycLoading: false, + isLedgerAccountPlaceholder: false, + readiness: { _tag: "Ready" }, + selectedYieldHasActivePosition: false, + providers: null, + validateAmount: true, + wallet: { + additionalAddresses: null, + address, + isLedgerLive: false, + }, + }, + submitted: false, + }); + + expect(view.preparation?.command.arguments?.amount).toBe("1.5"); + expect(view.validation.hasErrors).toBe(false); + }); + + it("truncates excess Enter precision and rejects dust that becomes zero", () => { + const selectedYield = yieldApiYieldFixture(); + const token = { ...selectedYield.token, decimals: 6 }; + const truncated = projectYieldEntry({ + input: { + additionalValidationErrors: undefined, + amountInitialization: "PreserveIntent", + availableAmount: new BigNumber("2"), + connected: true, + entry: { + amount: new BigNumber("1.123456789"), + selectedProviderYieldId: null, + token, + tronResource: null, + useMaxAmount: false, + validators: new Map(), + yield: selectedYield, + }, + externalProviders: false, + hasNoYields: false, + isKycBlocking: false, + isKycLoading: false, + isLedgerAccountPlaceholder: false, + readiness: { _tag: "Ready" }, + selectedYieldHasActivePosition: false, + providers: null, + validateAmount: true, + wallet: { + additionalAddresses: null, + address, + isLedgerLive: false, + }, + }, + submitted: false, + }); + const dust = projectYieldEntry({ + input: { + additionalValidationErrors: undefined, + amountInitialization: "PreserveIntent", + availableAmount: new BigNumber("2"), + connected: true, + entry: { + amount: new BigNumber("0.0000001"), + selectedProviderYieldId: null, + token, + tronResource: null, + useMaxAmount: false, + validators: new Map(), + yield: selectedYield, + }, + externalProviders: false, + hasNoYields: false, + isKycBlocking: false, + isKycLoading: false, + isLedgerAccountPlaceholder: false, + readiness: { _tag: "Ready" }, + selectedYieldHasActivePosition: false, + providers: null, + validateAmount: true, + wallet: { + additionalAddresses: null, + address, + isLedgerLive: false, + }, + }, + submitted: false, + }); + + expect(truncated.preparation?.command.arguments?.amount).toBe("1.123456"); + expect(dust.validation.errors.stakeAmountIsZero).toBe(true); + }); + + it("rejects Enter amounts that fall below the minimum after truncation", () => { + const selectedYield = yieldApiYieldFixture({ + mechanics: { + ...yieldApiYieldDtoFixture().mechanics, + arguments: { + enter: { + fields: [ + { + label: "Amount", + minimum: "1.123457", + name: "amount", + required: true, + type: "string", + }, + ], + }, + exit: { fields: [] }, + }, + }, + }); + const view = projectYieldEntry({ + input: { + additionalValidationErrors: undefined, + amountInitialization: "PreserveIntent", + availableAmount: new BigNumber("2"), + connected: true, + entry: { + amount: new BigNumber("1.123456789"), + selectedProviderYieldId: null, + token: { ...selectedYield.token, decimals: 6 }, + tronResource: null, + useMaxAmount: false, + validators: new Map(), + yield: selectedYield, + }, + externalProviders: false, + hasNoYields: false, + isKycBlocking: false, + isKycLoading: false, + isLedgerAccountPlaceholder: false, + readiness: { _tag: "Ready" }, + selectedYieldHasActivePosition: false, + providers: null, + validateAmount: true, + wallet: { + additionalAddresses: null, + address, + isLedgerLive: false, + }, + }, + submitted: false, + }); + + expect(view.preparation?.command.arguments?.amount).toBe("1.123456"); + expect(view.validation.errors.stakeAmountLessThanMin).toBe(true); + }); + + it("preserves a high-precision Pending Action amount through Manage command construction", () => { + const integration = yieldApiYieldFixture(); + const preciseAmount = "1.123456789012345678"; + const pendingActionEncoded = { + intent: "manage" as const, + passthrough: "claim", + type: "CLAIM_REWARDS" as const, + arguments: { + fields: [ + { + label: "Amount", + maximum: preciseAmount, + minimum: "0.1", + name: "amount" as const, + required: true, + type: "string" as const, + }, + ], + }, + }; + const yieldBalance = Schema.decodeSync(EarnBalance)( + yieldBalanceFixture({ + address, + amount: preciseAmount, + pendingActions: [pendingActionEncoded], + token: integration.token, + type: "claimable", + }) + ); + const pendingAction = yieldBalance.pendingActions[0]!; + const amount = new BigNumber(preciseAmount); + const prepared = preparePendingActionCommand({ + additionalAddresses: null, + address, + integration, + pendingAction, + pendingActionsState: new Map([ + [ + getPendingActionStateKey({ + actionType: pendingAction.type, + balanceType: yieldBalance.type, + passthrough: pendingAction.passthrough, + token: yieldBalance.token, + }), + amount, + ], + ]), + selectedValidators: [], + yieldBalance, + }); + + expect(Result.getOrThrow(prepared).command.arguments?.amount).toBe( + preciseAmount + ); + }); + + it("Review and Complete represent the truncated Action Command amount", () => { + const integration = yieldApiYieldFixture(); + const token = { ...integration.token, decimals: 6 }; + const yieldBalance = Schema.decodeSync(EarnBalance)( + yieldBalanceFixture({ + address, + amount: "2", + pendingActions: [ + { + intent: "manage" as const, + passthrough: "claim", + type: "CLAIM_REWARDS" as const, + arguments: { + fields: [ + { + label: "Amount", + maximum: "2", + minimum: "0.1", + name: "amount" as const, + required: true, + type: "string" as const, + }, + ], + }, + }, + ], + token, + type: "claimable", + }) + ); + const pendingAction = yieldBalance.pendingActions[0]!; + const prepared = preparePendingActionCommand({ + additionalAddresses: null, + address, + integration, + pendingAction, + pendingActionsState: new Map([ + [ + getPendingActionStateKey({ + actionType: pendingAction.type, + balanceType: yieldBalance.type, + passthrough: pendingAction.passthrough, + token, + }), + new BigNumber("1.123456789"), + ], + ]), + selectedValidators: [], + yieldBalance: { ...yieldBalance, token }, + }); + const commandAmount = Result.getOrThrow(prepared).command.arguments?.amount; + + expect(commandAmount).toBe("1.123456"); + expect(defaultFormattedNumber(commandAmount ?? "0")).toBe("1.123456"); + }); + + it("rejects a Manage amount that truncates below one base unit", () => { + const integration = yieldApiYieldFixture(); + const token = { ...integration.token, decimals: 6 }; + const yieldBalance = Schema.decodeSync(EarnBalance)( + yieldBalanceFixture({ + address, + amount: "1", + pendingActions: [ + { + intent: "manage" as const, + passthrough: "claim", + type: "CLAIM_REWARDS" as const, + arguments: { + fields: [ + { + label: "Amount", + maximum: "1", + minimum: "0.1", + name: "amount" as const, + required: true, + type: "string" as const, + }, + ], + }, + }, + ], + token, + type: "claimable", + }) + ); + const pendingAction = yieldBalance.pendingActions[0]!; + const prepared = preparePendingActionCommand({ + additionalAddresses: null, + address, + integration, + pendingAction, + pendingActionsState: new Map([ + [ + getPendingActionStateKey({ + actionType: pendingAction.type, + balanceType: yieldBalance.type, + passthrough: pendingAction.passthrough, + token: yieldBalance.token, + }), + new BigNumber("0.0000001"), + ], + ]), + selectedValidators: [], + yieldBalance: { ...yieldBalance, token }, + }); + + expect(Result.isFailure(prepared)).toBe(true); + }); +}); diff --git a/packages/widget/tests/domain/action-models.test.ts b/packages/widget/tests/domain/action-models.test.ts new file mode 100644 index 000000000..6c57e0397 --- /dev/null +++ b/packages/widget/tests/domain/action-models.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "@effect/vitest"; +import BigNumber from "bignumber.js"; +import { Effect, Logger, References, Schema } from "effect"; +import { + ActionCommand, + PendingAction, + TransactionGasEstimateJson, + YieldAction, +} from "../../src/domain/action/models"; +import { + yieldApiActionDtoFixture, + yieldApiTransactionDtoFixture, +} from "../fixtures"; + +const transaction = yieldApiTransactionDtoFixture({ + gasEstimate: JSON.stringify({ + amount: "0.01", + token: { + decimals: 18, + name: "Ethereum", + network: "ethereum", + symbol: "ETH", + }, + }), + id: "transaction-1", + network: "ethereum", +}); + +const action = yieldApiActionDtoFixture({ + address: "0xWallet", + id: "action-1", + transactions: [transaction], + yieldId: "ethereum-eth-native-staking", +}); + +describe("action application schemas", () => { + it("brands command and response identifiers at the application boundary", () => { + const command = Schema.decodeSync(ActionCommand)({ + address: "0xWallet", + arguments: { amount: "1" }, + yieldId: "ethereum-eth-native-staking", + }); + const model = Schema.decodeSync(YieldAction)(action); + + expect(command.yieldId).toBe("ethereum-eth-native-staking"); + expect(model.id).toBe("action-1"); + expect(model.transactions[0]?.id).toBe("transaction-1"); + }); + + it("strictly rejects a malformed nested transaction", () => { + expect(() => + Schema.decodeSync(YieldAction)({ + ...action, + transactions: [{ ...transaction, id: "" }], + }) + ).toThrow(); + }); + + it.effect("rejects invalid required timestamps", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + Schema.decodeEffect(YieldAction)({ + ...action, + createdAt: "invalid", + }) + ); + + expect(() => { + throw failure; + }).toThrow(); + }) + ); + + it.effect( + "safely clears invalid nullable timestamps and emits structured warnings", + () => + Effect.gen(function* () { + const annotations: Array> = []; + const logger = Logger.make((options) => { + annotations.push({ + ...options.fiber.getRef(References.CurrentLogAnnotations), + }); + }); + const decoded = yield* Schema.decodeEffect(YieldAction)({ + ...action, + completedAt: "invalid-completion", + transactions: [ + { + ...transaction, + broadcastedAt: "invalid-broadcast", + }, + ], + }).pipe(Effect.provide(Logger.layer([logger]))); + + expect(decoded.completedAt).toBeNull(); + expect(decoded.transactions[0]?.broadcastedAt).toBeNull(); + expect(annotations).toHaveLength(2); + expect(annotations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "api_decode_field_rejection", + operation: "yield-action", + field: "completedAt", + }), + expect.objectContaining({ + event: "api_decode_field_rejection", + operation: "yield-action-transaction", + field: "broadcastedAt", + }), + ]) + ); + expect(JSON.stringify(annotations)).not.toContain("invalid-completion"); + expect(JSON.stringify(annotations)).not.toContain("invalid-broadcast"); + }) + ); + + it("decodes transaction gas JSON only through Effect Schema", () => { + const gas = Schema.decodeUnknownSync(TransactionGasEstimateJson)( + transaction.gasEstimate + ); + + expect(gas.amount).toBeInstanceOf(BigNumber); + expect(gas.amount.toFixed()).toBe("0.01"); + expect(() => + Schema.decodeSync(TransactionGasEstimateJson)("not-json") + ).toThrow(); + }); + + it("keeps Pending Action argument notes from the argument schema", () => { + const pendingAction = Schema.decodeSync(PendingAction)({ + intent: "manage", + passthrough: "claim", + type: "CLAIM_REWARDS", + arguments: { + notes: "Claim before the period ends", + fields: [ + { + label: "Amount", + name: "amount", + required: true, + type: "string", + }, + ], + }, + }); + + expect(pendingAction.arguments?.notes).toBe("Claim before the period ends"); + }); +}); diff --git a/packages/widget/tests/domain/api-boundary-decisions.test.ts b/packages/widget/tests/domain/api-boundary-decisions.test.ts new file mode 100644 index 000000000..649cc3f21 --- /dev/null +++ b/packages/widget/tests/domain/api-boundary-decisions.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "@effect/vitest"; +import BigNumber from "bignumber.js"; +import { Effect, Logger, References, Schema } from "effect"; +import { logDecodeRejection } from "../../src/domain/decoding/decode-diagnostics"; +import { ExactDecimal } from "../../src/domain/finance/scalars"; + +describe("API boundary foundation decisions", () => { + it("decodes precision-sensitive decimals to BigNumber without precision loss", () => { + const value = Schema.decodeSync(ExactDecimal)( + "12345678901234567890.123456789012345678" + ); + + expect(BigNumber.isBigNumber(value)).toBe(true); + expect(value.toFixed()).toBe("12345678901234567890.123456789012345678"); + expect(Schema.encodeSync(ExactDecimal)(value)).toBe( + "12345678901234567890.123456789012345678" + ); + expect(() => Schema.decodeSync(ExactDecimal)("NaN")).toThrow(); + }); + + it.effect( + "emits structured warning diagnostics without the rejected value", + () => + Effect.gen(function* () { + const annotations: Array> = []; + const logger = Logger.make((options) => { + annotations.push({ + ...options.fiber.getRef(References.CurrentLogAnnotations), + }); + }); + + yield* logDecodeRejection({ + operation: "yield-catalog", + location: 2, + identifier: "yield-id", + issue: "id is missing", + }).pipe(Effect.provide(Logger.layer([logger]))); + + expect(annotations).toEqual([ + { + event: "api_decode_rejection", + operation: "yield-catalog", + location: "2", + identifier: "yield-id", + issue: "id is missing", + }, + ]); + }) + ); +}); diff --git a/packages/widget/tests/domain/api-boundary-errors.test.ts b/packages/widget/tests/domain/api-boundary-errors.test.ts new file mode 100644 index 000000000..45d866e48 --- /dev/null +++ b/packages/widget/tests/domain/api-boundary-errors.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { withApiRequestError } from "../../src/services/api/api-operation"; +import { + ApiRequestError, + type InputValidationError, + ResponseDecodeError, +} from "../../src/services/api/resource-sources"; + +type ApiBoundaryError = + | ApiRequestError + | InputValidationError + | ResponseDecodeError; + +const classify = (error: ApiBoundaryError) => + Effect.fail(error).pipe( + Effect.catchTags({ + ApiRequestError: () => Effect.succeed("api" as const), + ResponseDecodeError: () => Effect.succeed("decode" as const), + }) + ); + +describe("API boundary errors", () => { + it.effect("distinguishes API, decode, and absence failures by tag", () => + Effect.gen(function* () { + const requestError = new ApiRequestError({ + operation: "yield-list", + cause: new Error("network"), + }); + expect(requestError.richError).toBeNull(); + + expect(yield* classify(requestError)).toBe("api"); + expect( + yield* classify( + new ResponseDecodeError({ + operation: "yield-list", + issue: "missing id", + cause: new Error("schema"), + }) + ) + ).toBe("decode"); + }) + ); + + it.effect( + "retains a validated rich error on normalized API request failures", + () => + Effect.gen(function* () { + const error = yield* Effect.fail({ + cause: { + details: { code: "TEST" }, + message: "Rich failure", + }, + request: { url: "https://api.example.com/v1/tokens" }, + }).pipe(withApiRequestError("token-options"), Effect.flip); + + expect(error.richError).toEqual({ + details: { code: "TEST" }, + message: "Rich failure", + }); + }) + ); + + it.effect.each([ + { + cause: new Error("network unavailable"), + label: "plain network error", + }, + { + cause: { + cause: { details: "invalid", message: "Invalid details" }, + request: { url: "https://api.example.com/v1/tokens" }, + }, + label: "invalid payload", + }, + { + cause: { + cause: { message: "Access denied", type: "GEO_LOCATION" }, + request: { url: "https://api.example.com/v1/tokens" }, + }, + label: "geo-block payload", + }, + { + cause: { + reason: { + description: JSON.stringify({ + message: "Access denied", + type: "GEO_LOCATION", + }), + response: { + request: { url: "https://api.example.com/v1/tokens" }, + }, + }, + }, + label: "JSON-encoded geo-block payload", + }, + { + cause: { + cause: { message: "Gas estimate failed" }, + request: { url: "https://api.example.com/v1/gas-estimate" }, + }, + label: "gas-estimate response", + }, + ])("does not retain rich error data for $label", ({ cause }) => + Effect.gen(function* () { + const error = yield* Effect.fail(cause).pipe( + withApiRequestError("excluded-operation"), + Effect.flip + ); + + expect(error.richError).toBeNull(); + }) + ); +}); diff --git a/packages/widget/tests/domain/api-response-schema.test.ts b/packages/widget/tests/domain/api-response-schema.test.ts new file mode 100644 index 000000000..8e85557b2 --- /dev/null +++ b/packages/widget/tests/domain/api-response-schema.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "@effect/vitest"; +import BigNumber from "bignumber.js"; +import { Effect, Logger, References, Schema, SchemaGetter } from "effect"; +import { + TolerantTopLevelArray, + TolerantTopLevelRecord, +} from "../../src/domain/decoding/response-schema"; +import { ExactDecimal } from "../../src/domain/finance/scalars"; + +const ItemId = Schema.String.pipe(Schema.brand("ResponseSchemaTestItemId")); +const Item = Schema.Struct({ + id: ItemId, + amount: ExactDecimal, + nested: Schema.Struct({ enabled: Schema.Boolean }), +}); + +const ItemIdentifier = Schema.Struct({ id: Schema.String }).pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transform((value) => value.id), + encode: SchemaGetter.forbidden(() => "Identifier schema is decode-only"), + }) +); + +const Items = TolerantTopLevelArray(Item, { + operation: "test-items", + identifier: ItemIdentifier, +}); + +const Envelope = Schema.Struct({ + items: Items, + total: Schema.Finite, + offset: Schema.Finite, +}); + +const validItem = (id: string, amount = "1.25") => ({ + id, + amount, + nested: { enabled: true }, +}); + +const captureDiagnostics = (effect: Effect.Effect) => { + const annotations: Array> = []; + const logger = Logger.make((options) => { + annotations.push({ + ...options.fiber.getRef(References.CurrentLogAnnotations), + }); + }); + + return { + annotations, + result: effect.pipe(Effect.provide(Logger.layer([logger]))), + }; +}; + +describe("API response schemas", () => { + it.effect("strictly decodes a valid single domain model", () => + Effect.gen(function* () { + const result = yield* Schema.decodeEffect(Item)(validItem("item-1")); + + expect(result.id).toBe("item-1"); + expect(BigNumber.isBigNumber(result.amount)).toBe(true); + expect(result.amount.toFixed()).toBe("1.25"); + }) + ); + + it.effect("rejects a malformed single domain model", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + Schema.decodeUnknownEffect(Item)({ + ...validItem("item-1"), + nested: { enabled: "yes" }, + }) + ); + + expect(() => { + throw failure; + }).toThrow(/Expected boolean/); + }) + ); + + it.effect("rejects a malformed response envelope", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + Schema.decodeUnknownEffect(Envelope)({ + items: [validItem("item-1")], + total: "1", + offset: 0, + }) + ); + + expect(() => { + throw failure; + }).toThrow(/Expected number/); + }) + ); + + it.effect( + "keeps valid items in source order and rejects complete invalid parents", + () => + Effect.gen(function* () { + const decoded = captureDiagnostics( + Schema.decodeEffect(Envelope)({ + items: [ + validItem("item-1"), + { + ...validItem("item-nested-invalid"), + nested: { enabled: "yes" }, + }, + validItem("item-2", "9007199254740993.000000000000000001"), + validItem("item-amount-invalid", "NaN"), + ], + total: 4, + offset: 20, + }) + ); + + const result = yield* decoded.result; + + expect(result.items.map((item) => item.id)).toEqual([ + "item-1", + "item-2", + ]); + expect(result.items[1]?.amount.toFixed()).toBe( + "9007199254740993.000000000000000001" + ); + expect(result.total).toBe(4); + expect(result.offset).toBe(20); + expect(decoded.annotations).toEqual([ + expect.objectContaining({ + event: "api_decode_rejection", + operation: "test-items", + location: "1", + identifier: "item-nested-invalid", + }), + expect.objectContaining({ + event: "api_decode_rejection", + operation: "test-items", + location: "3", + identifier: "item-amount-invalid", + }), + ]); + expect(JSON.stringify(decoded.annotations)).not.toContain( + 'nested":{"enabled":"yes"}' + ); + }) + ); + + it.effect( + "returns an empty collection when every top-level item is rejected", + () => + Effect.gen(function* () { + const result = yield* Schema.decodeEffect(Items)([ + validItem("item-1", "NaN"), + { id: "item-2", amount: "2", nested: { enabled: "yes" } }, + ]); + + expect(result).toEqual([]); + }) + ); + + it.effect("omits an entire key-value entry when its key or value fails", () => + Effect.gen(function* () { + const RecordKey = Schema.String.check(Schema.isPattern(/^item-/)).pipe( + Schema.brand("ResponseSchemaTestRecordKey") + ); + const RecordResponse = TolerantTopLevelRecord(RecordKey, Item, { + operation: "test-record", + identifier: ItemIdentifier, + }); + const decoded = captureDiagnostics( + Schema.decodeEffect(RecordResponse)({ + "item-a": validItem("item-a"), + invalid: validItem("item-invalid-key"), + "item-b": validItem("item-b", "NaN"), + }) + ); + + const result = yield* decoded.result; + + expect(Object.keys(result)).toEqual(["item-a"]); + expect(Object.values(result)[0]?.id).toBe("item-a"); + expect(decoded.annotations).toEqual([ + expect.objectContaining({ + location: "invalid", + identifier: "item-invalid-key", + issue: expect.stringContaining("key:"), + }), + expect.objectContaining({ + location: "item-b", + identifier: "item-b", + issue: expect.stringContaining("value:"), + }), + ]); + }) + ); +}); diff --git a/packages/widget/tests/domain/app-scalars-identifiers.test.ts b/packages/widget/tests/domain/app-scalars-identifiers.test.ts new file mode 100644 index 000000000..97ce89343 --- /dev/null +++ b/packages/widget/tests/domain/app-scalars-identifiers.test.ts @@ -0,0 +1,51 @@ +import { DateTime, Schema } from "effect"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + ExactBaseUnitAmount, + ExactDecimal, + UtcDateTimeFromString, +} from "../../src/domain/finance/scalars"; +import { + type ActionId, + type ProviderId, + TokenAddress, + type TransactionId, + type YieldId, +} from "../../src/domain/identity/identifiers"; + +describe("application scalar and identifier schemas", () => { + it("uses lossless representations for raw units and decimal values", () => { + expect( + Schema.decodeSync(ExactBaseUnitAmount)( + "900719925474099312345678901234567890" + ) + ).toBe(900719925474099312345678901234567890n); + expect( + Schema.decodeSync(ExactDecimal)( + "9007199254740993.000000000000000001" + ).toFixed() + ).toBe("9007199254740993.000000000000000001"); + }); + + it("rejects invalid dates and normalizes valid date-times to UTC", () => { + const dateTime = Schema.decodeSync(UtcDateTimeFromString)( + "2026-07-10T14:00:00+02:00" + ); + + expect(DateTime.formatIso(dateTime)).toBe("2026-07-10T12:00:00.000Z"); + expect(() => + Schema.decodeSync(UtcDateTimeFromString)("not-a-date") + ).toThrow(); + }); + + it("keeps token addresses opaque", () => { + expect(Schema.decodeSync(TokenAddress)("CaseSensitiveAddress")).toBe( + "CaseSensitiveAddress" + ); + }); + + it("keeps identifier roles distinct at compile time", () => { + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); + }); +}); diff --git a/packages/widget/tests/domain/catalog-utilities.test.ts b/packages/widget/tests/domain/catalog-utilities.test.ts new file mode 100644 index 000000000..bc4f9be37 --- /dev/null +++ b/packages/widget/tests/domain/catalog-utilities.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { Effect } from "effect"; +import { loadAllPagesByIdChunks } from "../../src/shared/effect/pagination"; + +type Item = { + id: string; +}; + +const makeItem = (id: string): Item => ({ id }); + +describe("loadAllPagesByIdChunks", () => { + it.effect("splits IDs into bounded chunks", () => + Effect.gen(function* () { + const ids = Array.from({ length: 5 }, (_, index) => `yield-${index}`); + const fetchPage = vi.fn(({ ids }: { ids: ReadonlyArray }) => + Effect.succeed({ + total: ids.length, + items: ids.map(makeItem), + }) + ); + + const result = yield* loadAllPagesByIdChunks({ + chunkSize: 2, + concurrency: 2, + fetchPage, + getItemId: (item) => item.id, + ids, + pageSize: 100, + }); + + expect(result.map((item) => item.id)).toEqual(ids); + expect(fetchPage).toHaveBeenCalledTimes(3); + expect(fetchPage.mock.calls.map(([arg]) => arg.ids)).toEqual([ + ["yield-0", "yield-1"], + ["yield-2", "yield-3"], + ["yield-4"], + ]); + }) + ); + + it.effect( + "deduplicates requested IDs and returns items in first occurrence order", + () => + Effect.gen(function* () { + const fetchPage = vi.fn(({ ids }: { ids: ReadonlyArray }) => + Effect.succeed({ + total: ids.length, + items: [...ids].reverse().map(makeItem), + }) + ); + + const result = yield* loadAllPagesByIdChunks({ + chunkSize: 2, + concurrency: 2, + fetchPage, + getItemId: (item) => item.id, + ids: ["yield-2", "yield-1", "yield-2", "yield-0"], + pageSize: 100, + }); + + expect(result.map((item) => item.id)).toEqual([ + "yield-2", + "yield-1", + "yield-0", + ]); + expect(fetchPage.mock.calls.map(([arg]) => arg.ids)).toEqual([ + ["yield-2", "yield-1"], + ["yield-0"], + ]); + }) + ); + + it.effect("loads additional pages for each chunk", () => + Effect.gen(function* () { + const fetchPage = vi.fn( + ({ ids, offset }: { ids: ReadonlyArray; offset: number }) => + Effect.succeed({ + total: ids.length, + items: ids.slice(offset, offset + 2).map(makeItem), + }) + ); + + const result = yield* loadAllPagesByIdChunks({ + chunkSize: 3, + concurrency: 2, + fetchPage, + getItemId: (item) => item.id, + ids: ["yield-0", "yield-1", "yield-2"], + pageSize: 2, + }); + + expect(result.map((item) => item.id)).toEqual([ + "yield-0", + "yield-1", + "yield-2", + ]); + expect(fetchPage.mock.calls.map(([arg]) => arg.offset)).toEqual([0, 2]); + }) + ); +}); diff --git a/packages/widget/tests/domain/dashboard-models.test.ts b/packages/widget/tests/domain/dashboard-models.test.ts new file mode 100644 index 000000000..d6be3f18e --- /dev/null +++ b/packages/widget/tests/domain/dashboard-models.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "@effect/vitest"; +import { DateTime, Effect, Schema } from "effect"; +import { + KycStatus, + RewardRateHistoryResponse, + RewardsSummaryRecord, + TvlHistoryResponse, +} from "../../src/domain/portfolio/models"; + +const envelope = { + from: "2026-06-01T00:00:00.000Z", + interval: "day", + limit: 2, + offset: 0, + to: "2026-07-01T00:00:00.000Z", + total: 2, + yieldId: "ethereum-eth-native-staking", +} as const; + +const token = { + decimals: 18, + name: "Ethereum", + network: "ethereum", + symbol: "ETH", +} as const; + +describe("dashboard application schemas", () => { + it("strictly validates KYC singles", () => { + expect(Schema.decodeSync(KycStatus)({ kycStatus: "approved" })).toEqual({ + kycStatus: "approved", + }); + expect(() => + Schema.decodeUnknownSync(KycStatus)({ kycStatus: "unknown" }) + ).toThrow(); + }); + + it.effect("omits malformed reward-rate and TVL points independently", () => + Effect.gen(function* () { + const rewardRate = yield* Schema.decodeEffect(RewardRateHistoryResponse)({ + ...envelope, + items: [ + { timestamp: "2026-06-01T00:00:00.000Z", rewardRate: "0.05" }, + { timestamp: "invalid", rewardRate: "0.07" }, + ], + }); + const tvl = yield* Schema.decodeEffect(TvlHistoryResponse)({ + ...envelope, + items: [ + { + timestamp: "2026-06-01T00:00:00.000Z", + tvl: "1000.5", + tvlRaw: "1000500000", + }, + { + timestamp: "2026-06-02T00:00:00.000Z", + tvl: null, + tvlRaw: "0", + }, + ], + }); + + expect(rewardRate.items).toHaveLength(1); + expect(rewardRate.items[0]?.rewardRate.toFixed()).toBe("0.05"); + expect(DateTime.isDateTime(rewardRate.from)).toBe(true); + expect(DateTime.isDateTime(rewardRate.to)).toBe(true); + expect(DateTime.isDateTime(rewardRate.items[0]?.timestamp)).toBe(true); + expect(tvl.items).toHaveLength(1); + expect(tvl.items[0]?.tvl.toFixed()).toBe("1000.5"); + expect(tvl.items[0]?.tvlRaw).toBe("1000500000"); + expect(DateTime.isDateTime(tvl.items[0]?.timestamp)).toBe(true); + }) + ); + + it.effect( + "omits a malformed reward summary while retaining valid siblings", + () => + Effect.gen(function* () { + const rewards = { + last24H: "0", + last30D: "3", + last7D: "1", + lastYear: "12", + total: "20", + }; + const decoded = yield* Schema.decodeEffect(RewardsSummaryRecord)({ + "ethereum-eth-native-staking": { rewards, token }, + "cosmos-atom-native-staking": { + rewards, + token: { ...token, decimals: "invalid" }, + }, + }); + + expect(Object.keys(decoded)).toEqual(["ethereum-eth-native-staking"]); + }) + ); +}); diff --git a/packages/widget/tests/domain/dashboard-yield-category-types.test.ts b/packages/widget/tests/domain/dashboard-yield-category-types.test.ts index 6ab60bb4c..6e62943ce 100644 --- a/packages/widget/tests/domain/dashboard-yield-category-types.test.ts +++ b/packages/widget/tests/domain/dashboard-yield-category-types.test.ts @@ -1,14 +1,12 @@ import { describe, expect, it } from "vitest"; +import type { EarnYieldWithProvider } from "../../src/domain/earn/models"; import { - DashboardYieldCategory, dashboardYieldCategories, getApiYieldTypesForDashboardCategory, getDashboardYieldCategory, getYieldTypeLabels, getYieldTypesSortRank, - normalizeDashboardYieldCategoryOrder, - type YieldBase, -} from "../../src/domain/types/yields"; +} from "../../src/domain/earn/yield"; const allApiYieldTypes = [ "staking", @@ -22,7 +20,7 @@ const allApiYieldTypes = [ "liquid_staking", ] as const; -const makeYield = (type: string): YieldBase => +const makeYield = (type: string): EarnYieldWithProvider => ({ mechanics: { type, @@ -31,7 +29,7 @@ const makeYield = (type: string): YieldBase => network: "ethereum", symbol: "USDC", }, - }) as YieldBase; + }) as EarnYieldWithProvider; const t = ((key: string) => { const values: Record = { @@ -79,51 +77,6 @@ describe("getApiYieldTypesForDashboardCategory", () => { }); }); -describe("normalizeDashboardYieldCategoryOrder", () => { - it("uses the default category order when no order is provided", () => { - expect(normalizeDashboardYieldCategoryOrder()).toEqual([ - "rwa", - "defi", - "stake", - ]); - }); - - it("preserves a complete custom category order", () => { - expect( - normalizeDashboardYieldCategoryOrder([ - DashboardYieldCategory.Stake, - DashboardYieldCategory.DeFi, - DashboardYieldCategory.RWA, - ]) - ).toEqual([ - DashboardYieldCategory.Stake, - DashboardYieldCategory.DeFi, - DashboardYieldCategory.RWA, - ]); - }); - - it("deduplicates categories by keeping their first occurrence", () => { - expect( - normalizeDashboardYieldCategoryOrder([ - DashboardYieldCategory.Stake, - DashboardYieldCategory.DeFi, - DashboardYieldCategory.Stake, - DashboardYieldCategory.RWA, - ]) - ).toEqual([ - DashboardYieldCategory.Stake, - DashboardYieldCategory.DeFi, - DashboardYieldCategory.RWA, - ]); - }); - - it("ignores runtime-invalid entries and appends missing valid categories", () => { - expect( - normalizeDashboardYieldCategoryOrder(["stake", "invalid", "defi"]) - ).toEqual(["stake", "defi", "rwa"]); - }); -}); - describe("getYieldTypesSortRank", () => { it("ranks RWA yields before other API yield types", () => { const rwaRank = getYieldTypesSortRank(makeYield("real_world_asset")); diff --git a/packages/widget/tests/domain/earn-boundary.test.ts b/packages/widget/tests/domain/earn-boundary.test.ts new file mode 100644 index 000000000..61d349709 --- /dev/null +++ b/packages/widget/tests/domain/earn-boundary.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Logger, Schema } from "effect"; +import { + EarnLegacyTokenOptionsResponse, + EarnPositionsResponse, + EarnProvider, + EarnValidatorPage, + EarnYield, + EarnYieldPage, +} from "../../src/domain/earn/models"; +import { TokenBalancesResponse } from "../../src/domain/finance/models"; +import { yieldApiYieldDtoFixture } from "../fixtures"; + +const token = { + name: "Ethereum", + symbol: "ETH", + decimals: 18, + network: "ethereum", + address: "0xAbCd", +} as const; + +const decode = >( + schema: S, + input: unknown +) => + Schema.decodeUnknownEffect(schema)(input).pipe( + Effect.provide(Logger.layer([])) + ); + +describe("Earn API boundary policies", () => { + it.effect( + "keeps valid catalog entries and omits a complete nested-invalid yield", + () => + Effect.gen(function* () { + const valid = yieldApiYieldDtoFixture({ prime: false }); + const result = yield* decode(EarnYieldPage, { + items: [ + valid, + { + ...valid, + id: "invalid-yield", + token: { ...valid.token, decimals: "18" }, + }, + ], + limit: 100, + offset: 0, + total: 2, + }); + + expect(result.items?.map((item) => item.id)).toEqual([valid.id]); + expect(result.total).toBe(2); + }) + ); + + it.effect( + "omits only the yield with an invalid consumed mechanic argument", + () => + Effect.gen(function* () { + const valid = yieldApiYieldDtoFixture({ prime: false }); + const result = yield* decode(EarnYieldPage, { + items: [ + valid, + { + ...valid, + id: "invalid-mechanic-yield", + mechanics: { + ...valid.mechanics, + arguments: { + enter: { + fields: [ + { + label: "Amount", + minimum: "not-a-number", + name: "amount", + type: "string", + }, + ], + }, + }, + }, + }, + ], + limit: 100, + offset: 0, + total: 2, + }); + + expect(result.items?.map((item) => item.id)).toEqual([valid.id]); + }) + ); + + it.effect( + "returns an empty catalog when every top-level yield is invalid", + () => + Effect.gen(function* () { + const valid = yieldApiYieldDtoFixture({ prime: false }); + const result = yield* decode(EarnYieldPage, { + items: [{ ...valid, id: "invalid-yield", prime: "no" }], + limit: 100, + offset: 0, + total: 1, + }); + + expect(result.items).toEqual([]); + }) + ); + + it.effect("strictly rejects a malformed initial yield", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + decode(EarnYield, { + ...yieldApiYieldDtoFixture({ prime: false }), + metadata: null, + }) + ); + expect(() => { + throw failure; + }).toThrow(); + }) + ); + + it.effect("strictly rejects a malformed provider", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + decode(EarnProvider, { + id: "", + name: "StakeKit", + description: "", + logoURI: + "https://assets.stakek.it/app/composition/providers/stakekit.svg", + website: "https://stakek.it", + tvlUsd: null, + type: "protocol", + }) + ); + expect(() => { + throw failure; + }).toThrow(); + }) + ); + + it.effect("omits malformed complete token options and balances", () => + Effect.gen(function* () { + const validOption = { + availableYields: ["ethereum-eth-native-staking"], + token, + }; + const malformedOption = { + ...validOption, + token: { ...token, decimals: "18" }, + }; + const [legacyOptions, balances] = yield* Effect.all( + [ + decode(EarnLegacyTokenOptionsResponse, [ + validOption, + malformedOption, + ]), + decode(TokenBalancesResponse, [ + { ...validOption, amount: "1.5" }, + { ...malformedOption, amount: "2" }, + ]), + ], + { concurrency: "unbounded" } + ); + + expect(legacyOptions).toHaveLength(1); + expect(balances).toHaveLength(1); + expect(balances[0]?.amount.toFixed()).toBe("1.5"); + }) + ); + + it.effect("partially decodes validators and derives stable keys", () => + Effect.gen(function* () { + const result = yield* decode(EarnValidatorPage, { + items: [ + { address: "validator-1", subnet: { id: 7 } }, + { name: "missing-address" }, + ], + limit: 100, + offset: 0, + total: 2, + }); + + expect(result.items?.map((item) => item.key)).toEqual(["validator-1:7"]); + }) + ); + + it.effect( + "rejects a complete position when a nested balance is invalid", + () => + Effect.gen(function* () { + const result = yield* decode(EarnPositionsResponse, { + errors: [], + items: [ + { + yieldId: "ethereum-eth-native-staking", + balances: [ + { + address: "wallet-1", + type: "active", + amount: "1.5", + amountRaw: "1500000000000000000", + pendingActions: [], + token, + isEarning: true, + }, + { + address: "wallet-1", + type: "active", + amount: "NaN", + amountRaw: "1", + pendingActions: [], + token, + isEarning: true, + }, + ], + }, + ], + }); + + expect(result.items).toEqual([]); + }) + ); +}); diff --git a/packages/widget/tests/domain/earn-models.test.ts b/packages/widget/tests/domain/earn-models.test.ts new file mode 100644 index 000000000..ad044fc57 --- /dev/null +++ b/packages/widget/tests/domain/earn-models.test.ts @@ -0,0 +1,547 @@ +import { describe, expect, it } from "@effect/vitest"; +import BigNumber from "bignumber.js"; +import { Effect, Logger, Schema } from "effect"; +import { + EarnBalance, + EarnPosition, + EarnProvider, + EarnValidator, + EarnYield, +} from "../../src/domain/earn/models"; +import { Token } from "../../src/domain/token/token"; +import { yieldApiYieldDtoFixture, yieldApiYieldFixture } from "../fixtures"; + +const token = { + name: "Ethereum", + symbol: "ETH", + decimals: 18, + network: "ethereum", + address: "0xAbCd", +} as const; + +describe("Earn application models", () => { + it("brands token/provider identifiers without changing case-sensitive addresses", () => { + const decodedToken = Schema.decodeSync(Token)(token); + const provider = Schema.decodeSync(EarnProvider)({ + id: "stakekit", + name: "StakeKit", + description: "", + logoURI: + "https://assets.stakek.it/app/composition/providers/stakekit.svg", + website: "https://stakek.it", + tvlUsd: null, + type: "protocol", + }); + + expect(decodedToken.address).toBe("0xAbCd"); + expect(provider.id).toBe("stakekit"); + }); + + it("derives stable validator keys through Schema", () => { + const validator = Schema.decodeSync(EarnValidator)({ + address: "validator-1", + subnet: { id: 7, name: "Subnet 7" }, + }); + + expect(validator.key).toBe("validator-1:7"); + }); + + it("uses lossless balance amount and raw-unit representations", () => { + const balance = Schema.decodeSync(EarnBalance)({ + address: "wallet-1", + type: "active", + amount: "9007199254740993.000000000000000001", + amountRaw: "9007199254740993000000000000000001", + pendingActions: [], + token, + amountUsd: "12345678901234567890.12", + isEarning: true, + }); + + expect(BigNumber.isBigNumber(balance.amount)).toBe(true); + expect(balance.amount.toFixed()).toBe( + "9007199254740993.000000000000000001" + ); + expect(balance.amountRaw).toBe(9007199254740993000000000000000001n); + expect(balance.amountUsd?.toFixed()).toBe("12345678901234567890.12"); + }); + + it.effect("safely omits an invalid optional balance timestamp", () => + Effect.gen(function* () { + const balance = yield* Schema.decodeEffect(EarnBalance)({ + address: "wallet-1", + type: "active", + amount: "1", + amountRaw: "1", + date: "invalid", + pendingActions: [], + token, + isEarning: true, + }).pipe(Effect.provide(Logger.layer([]))); + + expect(balance.date).toBeUndefined(); + }) + ); + + it("decodes complete yield and position models with branded yield IDs", () => { + const yieldModel = yieldApiYieldFixture({ prime: false }); + const position = Schema.decodeSync(EarnPosition)({ + yieldId: yieldModel.id, + balances: [ + { + address: "wallet-1", + type: "active", + amount: "1.5", + amountRaw: "1500000000000000000", + pendingActions: [], + token, + isEarning: true, + }, + ], + outputTokenBalance: null, + }); + + expect(yieldModel.id).toBe("ethereum-eth-native-staking"); + expect(position.yieldId).toBe(yieldModel.id); + expect(position.balances[0]?.amount.toFixed()).toBe("1.5"); + }); + + it("projects the generated API amount representation into normalized domain constraints", () => { + const baseYield = yieldApiYieldDtoFixture(); + const yieldModel = yieldApiYieldFixture({ + mechanics: { + ...baseYield.mechanics, + arguments: { + enter: { + fields: [ + { + label: "Amount", + minimum: null, + name: "amount", + type: "string", + }, + ], + }, + }, + }, + }); + + expect(yieldModel.mechanics.arguments?.enter?.fields).toEqual({ + amount: { + maximum: null, + minimum: new BigNumber(0), + required: false, + }, + }); + }); + + it("accepts the Cardano unbounded-maximum amount representation", () => { + const baseYield = yieldApiYieldDtoFixture(); + const yieldModel = yieldApiYieldFixture({ + mechanics: { + ...baseYield.mechanics, + arguments: { + enter: { + fields: [ + { + label: "Amount", + maximum: "-1", + minimum: "5", + name: "amount", + required: false, + type: "string", + }, + ], + }, + }, + }, + }); + + expect(yieldModel.mechanics.arguments?.enter?.fields.amount).toEqual({ + maximum: null, + minimum: new BigNumber(5), + required: false, + }); + }); + + it("accepts string-valued liquidity state from the Yield API", () => { + const yieldModel = yieldApiYieldFixture({ + state: { + liquidityState: { + liquidity: "8045570", + utilization: "0", + }, + }, + }); + + expect(yieldModel.state?.liquidityState).toEqual({ + liquidity: "8045570", + utilization: "0", + }); + }); + + it("keeps only widget-consumed mechanic argument fields", () => { + const baseYield = yieldApiYieldDtoFixture(); + const yieldModel = yieldApiYieldFixture({ + mechanics: { + ...baseYield.mechanics, + arguments: { + enter: { + fields: [ + { + label: "Amount", + maximum: "100", + minimum: "0.000000000000000001", + name: "amount", + required: true, + type: "string", + }, + { + label: "Provider", + name: "providerId", + options: ["provider-a"], + required: true, + type: "string", + }, + { + label: "Resource", + name: "tronResource", + options: ["BANDWIDTH", "ENERGY"], + type: "enum", + }, + { + label: "Validator", + name: "validatorAddress", + required: true, + type: "string", + }, + { + label: "Validators", + name: "validatorAddresses", + type: "string", + }, + { + label: "Subnet", + name: "subnetId", + type: "number", + }, + { + label: "Ignored", + name: "duration", + type: "number", + }, + ], + }, + }, + }, + }); + + expect(yieldModel.mechanics.arguments?.enter?.fields).toEqual({ + amount: { + maximum: new BigNumber(100), + minimum: new BigNumber("0.000000000000000001"), + required: true, + }, + providerId: { + options: ["provider-a"], + required: true, + }, + subnetId: { + required: false, + }, + tronResource: { + options: ["BANDWIDTH", "ENERGY"], + required: false, + }, + validatorAddress: { + required: true, + }, + validatorAddresses: { + required: false, + }, + }); + }); + + it("validates unconsumed mechanic fields through the API schema before projection", () => { + const baseYield = yieldApiYieldDtoFixture(); + + expect(() => + yieldApiYieldFixture({ + mechanics: { + ...baseYield.mechanics, + arguments: { + enter: { + fields: [ + { + name: "duration", + type: "number", + } as never, + ], + }, + }, + }, + }) + ).toThrow(); + }); + + it("projects consumed fields from every mechanic argument container", () => { + const baseYield = yieldApiYieldDtoFixture(); + const yieldModel = yieldApiYieldFixture({ + mechanics: { + ...baseYield.mechanics, + arguments: { + balance: { + fields: [ + { + label: "Resource", + name: "tronResource", + options: ["ENERGY"], + type: "enum", + }, + ], + }, + enter: { + fields: [ + { + label: "Amount", + name: "amount", + type: "string", + }, + ], + }, + exit: { + fields: [ + { + label: "Validator", + name: "validatorAddress", + required: true, + type: "string", + }, + ], + }, + manage: { + CLAIM_REWARDS: { + fields: [ + { + label: "Provider", + name: "providerId", + options: ["provider-a"], + type: "string", + }, + ], + }, + }, + }, + }, + }); + + expect(yieldModel.mechanics.arguments).toEqual({ + balance: { + fields: { + tronResource: { + options: ["ENERGY"], + required: false, + }, + }, + }, + enter: { + fields: { + amount: { + maximum: null, + minimum: new BigNumber(0), + required: false, + }, + }, + }, + exit: { + fields: { + validatorAddress: { + required: true, + }, + }, + }, + manage: { + CLAIM_REWARDS: { + fields: { + providerId: { + options: ["provider-a"], + required: false, + }, + }, + }, + }, + }); + }); + + it("projects exit receive-token options from the Yield API contract", () => { + const baseYield = yieldApiYieldDtoFixture(); + const yieldModel = yieldApiYieldFixture({ + mechanics: { + ...baseYield.mechanics, + arguments: { + exit: { + fields: [ + { + label: "Output Token", + name: "outputToken", + options: [ + "0xdC035D45d973E3EC169d2276DDab16f1e407384F", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + ], + required: false, + type: "string", + }, + ], + }, + }, + }, + }); + + expect(yieldModel.mechanics.arguments?.exit?.fields.outputToken).toEqual({ + options: [ + "0xdC035D45d973E3EC169d2276DDab16f1e407384F", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + ], + required: false, + }); + }); + + it.each([ + { + field: { + label: "Amount", + name: "amount", + type: "number", + }, + name: "an amount with a non-canonical type", + }, + { + field: { + label: "Amount", + minimum: "not-a-number", + name: "amount", + type: "string", + }, + name: "non-finite amount bounds", + }, + { + field: { + label: "Amount", + maximum: "2", + minimum: "3", + name: "amount", + type: "string", + }, + name: "an amount maximum below its minimum", + }, + { + field: { + label: "Amount", + maximum: null, + minimum: "-1", + name: "amount", + type: "string", + }, + name: "a partial force-max sentinel", + }, + { + field: { + label: "Provider", + name: "providerId", + options: [], + required: true, + type: "string", + }, + name: "a required provider without options", + }, + { + field: { + label: "Provider", + name: "providerId", + options: [""], + type: "string", + }, + name: "an invalid provider option", + }, + { + field: { + label: "Resource", + name: "tronResource", + options: ["COMPUTE"], + type: "enum", + }, + name: "an invalid Tron resource option", + }, + { + field: { + label: "Provider", + name: "providerId", + options: ["provider-a"], + type: "enum", + }, + name: "a provider with a non-canonical type", + }, + { + field: { + label: "Resource", + name: "tronResource", + options: ["ENERGY"], + type: "string", + }, + name: "a Tron resource with a non-canonical type", + }, + { + field: { + label: "Validator", + name: "validatorAddress", + type: "address", + }, + name: "a validator address with a non-canonical type", + }, + { + field: { + label: "Validators", + name: "validatorAddresses", + type: "address", + }, + name: "validator addresses with a non-canonical type", + }, + { + field: { + label: "Subnet", + name: "subnetId", + type: "string", + }, + name: "a subnet ID with a non-canonical type", + }, + ])("rejects $name", ({ field }) => { + const baseYield = yieldApiYieldDtoFixture(); + + expect(() => + yieldApiYieldFixture({ + mechanics: { + ...baseYield.mechanics, + arguments: { + enter: { + fields: [field as never], + }, + }, + }, + }) + ).toThrow(); + }); + + it("rejects a present argument container without a fields array", () => { + const valid = yieldApiYieldDtoFixture(); + + expect(() => + Schema.decodeUnknownSync(EarnYield)({ + ...valid, + mechanics: { + ...valid.mechanics, + arguments: { enter: {} }, + }, + }) + ).toThrow(); + }); +}); diff --git a/packages/widget/tests/domain/evm-chains.test.ts b/packages/widget/tests/domain/evm-chains.test.ts index 31439c0f4..988340b87 100644 --- a/packages/widget/tests/domain/evm-chains.test.ts +++ b/packages/widget/tests/domain/evm-chains.test.ts @@ -1,18 +1,67 @@ import { describe, expect, it } from "vitest"; -import { - EvmChainIds, - evmChainsMap, - supportedEVMChainsSet, -} from "../../src/domain/types/chains/evm"; -import { EvmNetworks } from "../../src/domain/types/chains/networks"; +import { EvmChainIds } from "../../src/public-api/types"; +import { evmChainsMap } from "../../src/services/wallet/internal/adapters/evm/chains"; describe("EVM chains", () => { + it("configures Robinhood mainnet and testnet", () => { + expect(EvmChainIds.Robinhood).toBe(4663); + expect(EvmChainIds.RobinhoodTestnet).toBe(46_630); + expect(evmChainsMap.robinhood).toMatchObject({ + type: "evm", + network: "robinhood", + wagmiChain: { + id: 4663, + name: "Robinhood Chain", + iconUrl: "https://assets.stakek.it/networks/robinhood.svg", + nativeCurrency: { + symbol: "ETH", + decimals: 18, + }, + rpcUrls: { + default: { + http: ["https://rpc.mainnet.chain.robinhood.com"], + }, + }, + blockExplorers: { + default: { + url: "https://robinhoodchain.blockscout.com", + }, + }, + }, + }); + expect(evmChainsMap.robinhood.wagmiChain.testnet).toBeUndefined(); + + expect(evmChainsMap["robinhood-testnet"]).toMatchObject({ + type: "evm", + network: "robinhood-testnet", + wagmiChain: { + id: 46_630, + name: "Robinhood Chain Testnet", + iconUrl: "https://assets.stakek.it/networks/robinhood-testnet.svg", + nativeCurrency: { + symbol: "ETH", + decimals: 18, + }, + rpcUrls: { + default: { + http: ["https://rpc.testnet.chain.robinhood.com"], + }, + }, + blockExplorers: { + default: { + url: "https://explorer.testnet.chain.robinhood.com", + }, + }, + testnet: true, + }, + }); + }); + it("configures Pharos with PROS as its native gas token", () => { - expect(supportedEVMChainsSet.has(EvmNetworks.Pharos)).toBe(true); expect(EvmChainIds.Pharos).toBe(1672); - expect(evmChainsMap[EvmNetworks.Pharos]).toMatchObject({ + expect(evmChainsMap.pharos).toMatchObject({ type: "evm", - skChainName: "pharos", + network: "pharos", wagmiChain: { id: 1672, name: "Pharos", diff --git a/packages/widget/tests/domain/finance-exact-values.test.ts b/packages/widget/tests/domain/finance-exact-values.test.ts new file mode 100644 index 000000000..d1fb9d1f1 --- /dev/null +++ b/packages/widget/tests/domain/finance-exact-values.test.ts @@ -0,0 +1,71 @@ +import BigNumber from "bignumber.js"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { + exactDecimal, + toSafeIntegerCount, +} from "../../src/domain/finance/exact"; +import { + ExactBaseUnitAmount, + ExactDecimal, +} from "../../src/domain/finance/scalars"; + +describe("exact finance schemas", () => { + it("decodes string and number decimals into equal BigNumber values", () => { + const fromString = Schema.decodeSync(ExactDecimal)("1.5"); + const fromNumber = Schema.decodeSync(ExactDecimal)(1.5); + + expect(BigNumber.isBigNumber(fromString)).toBe(true); + expect(fromString.isEqualTo(fromNumber)).toBe(true); + expect(Schema.encodeSync(ExactDecimal)(fromString)).toBe("1.5"); + }); + + it("preserves decimal precision that cannot pass through JavaScript number", () => { + const value = Schema.decodeSync(ExactDecimal)( + "9007199254740993.000000000000000001" + ); + + expect(value.toFixed()).toBe("9007199254740993.000000000000000001"); + expect(value.isEqualTo("9007199254740993.000000000000000001")).toBe(true); + }); + + it("rejects non-finite decimal inputs", () => { + expect(() => Schema.decodeSync(ExactDecimal)("NaN")).toThrow(); + expect(() => Schema.decodeSync(ExactDecimal)(Number.NaN)).toThrow(); + expect(() => + Schema.decodeSync(ExactDecimal)(Number.POSITIVE_INFINITY) + ).toThrow(); + }); + + it("decodes Base Unit Amounts from string or number into bigint", () => { + expect(Schema.decodeSync(ExactBaseUnitAmount)("12")).toBe(12n); + expect(Schema.decodeSync(ExactBaseUnitAmount)(12)).toBe(12n); + expect(Schema.decodeSync(ExactBaseUnitAmount)("1000000000000000001")).toBe( + 1000000000000000001n + ); + expect(Schema.encodeSync(ExactBaseUnitAmount)(12n)).toBe("12"); + }); + + it("rejects non-integer Base Unit Amount inputs", () => { + expect(() => Schema.decodeSync(ExactBaseUnitAmount)("1.5")).toThrow(); + expect(() => Schema.decodeSync(ExactBaseUnitAmount)(1.5)).toThrow(); + expect(() => + Schema.decodeSync(ExactBaseUnitAmount)(9_007_199_254_740_992) + ).toThrow(); + }); + + it("divides with 20 decimal places and half-up rounding", () => { + expect(new BigNumber(1).dividedBy(3).toFixed()).toBe( + "0.33333333333333333333" + ); + expect(new BigNumber(2).dividedBy(3).toFixed()).toBe( + "0.66666666666666666667" + ); + }); + + it("converts integer counts without using JavaScript number until the value is a safe integer", () => { + expect(toSafeIntegerCount(exactDecimal(3))).toBe(3); + expect(toSafeIntegerCount(exactDecimal("1.5"))).toBeNull(); + expect(toSafeIntegerCount(exactDecimal("9007199254740993"))).toBeNull(); + }); +}); diff --git a/packages/widget/tests/domain/financial-models.test.ts b/packages/widget/tests/domain/financial-models.test.ts new file mode 100644 index 000000000..041e5a6d2 --- /dev/null +++ b/packages/widget/tests/domain/financial-models.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "@effect/vitest"; +import BigNumber from "bignumber.js"; +import { Effect, Logger, Schema } from "effect"; +import { + GasBalancesCommand, + TokenBalanceScanCommand, + TokenBalancesResponse, + YieldBalancesCommand, +} from "../../src/domain/finance/models"; + +const token = { + name: "Ethereum", + symbol: "ETH", + decimals: 18, + network: "ethereum", + address: "0xAbCd", +} as const; + +const decode = >( + schema: S, + input: unknown +) => + Schema.decodeUnknownEffect(schema)(input).pipe( + Effect.provide(Logger.layer([])) + ); + +describe("financial API boundary schemas", () => { + it("decodes wallet-scoped balance commands into branded application values", () => { + const tokenCommand = Schema.decodeSync(TokenBalanceScanCommand)({ + addresses: { address: "0xWallet" }, + network: "ethereum", + }); + const yieldCommand = Schema.decodeSync(YieldBalancesCommand)({ + queries: [ + { + address: "0xWallet", + network: "ethereum", + yieldId: "ethereum-eth-native-staking", + }, + ], + }); + const gasCommand = Schema.decodeSync(GasBalancesCommand)({ + addresses: [ + { + address: "0xWallet", + network: "ethereum", + tokenAddress: "0xGasToken", + }, + ], + }); + + expect(tokenCommand.addresses.address).toBe("0xWallet"); + expect(gasCommand.addresses[0]?.tokenAddress).toBe("0xGasToken"); + expect(yieldCommand.queries[0]?.yieldId).toBe( + "ethereum-eth-native-staking" + ); + }); + + it.effect( + "omits a complete malformed token balance and retains valid siblings", + () => + Effect.gen(function* () { + const balances = yield* decode(TokenBalancesResponse, [ + { + amount: "1.5", + availableYields: ["ethereum-eth-native-staking"], + token, + }, + { + amount: "NaN", + availableYields: ["ethereum-eth-native-staking"], + token, + }, + { + amount: "2", + availableYields: ["ethereum-eth-native-staking"], + token: { ...token, decimals: "18" }, + }, + ]); + + expect(balances).toHaveLength(1); + expect(balances[0]?.amount).toBeInstanceOf(BigNumber); + expect(balances[0]?.amount.toFixed()).toBe("1.5"); + }) + ); + + it.effect( + "returns an empty balance list when every top-level entry is malformed", + () => + Effect.gen(function* () { + const balances = yield* decode(TokenBalancesResponse, [ + { + amount: "not-a-decimal", + availableYields: [], + token, + }, + ]); + + expect(balances).toEqual([]); + }) + ); +}); diff --git a/packages/widget/tests/domain/health-price-models.test.ts b/packages/widget/tests/domain/health-price-models.test.ts new file mode 100644 index 000000000..b7564c695 --- /dev/null +++ b/packages/widget/tests/domain/health-price-models.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { exactDecimal } from "../../src/domain/finance/exact"; +import { + HealthStatus, + PriceRequest, + PriceResponse, +} from "../../src/domain/health/models"; +import { Token } from "../../src/domain/token/token"; + +describe("health and price application schemas", () => { + it("strictly decodes health responses", () => { + const health = Schema.decodeSync(HealthStatus)({ + status: "OK", + timestamp: "2026-07-10T12:00:00.000Z", + }); + + expect(health.status).toBe("OK"); + expect(() => + Schema.decodeUnknownSync(HealthStatus)({ + status: "DEGRADED", + timestamp: "not-a-date", + }) + ).toThrow(); + }); + + it("decodes application price commands from the generated wire shape", () => { + const request = Schema.decodeSync(PriceRequest)({ + currency: "USD", + tokenList: [ + { + address: "0xToken", + decimals: 18, + name: "Ethereum", + network: "ethereum", + symbol: "ETH", + }, + ], + }); + + expect(request.tokenList[0]?.address).toBe("0xToken"); + }); + + it.effect( + "omits malformed top-level price entries while retaining valid siblings", + () => + Effect.gen(function* () { + const prices = yield* Schema.decodeEffect(PriceResponse)({ + "ethereum-": { price: 3000, price_24_h: 2900 }, + "cosmos-": { price: "invalid", price_24_h: 5 }, + "solana-": { price: 150 }, + }); + + expect(prices.value.get("ethereum-")).toEqual({ + price: exactDecimal(3000), + price24H: exactDecimal(2900), + }); + expect(prices.value.has("cosmos-")).toBe(false); + expect(prices.value.get("solana-")).toEqual({ + price: exactDecimal(150), + price24H: undefined, + }); + }) + ); + + it.effect( + "looks up legacy price keys without conflating them with Token identity", + () => + Effect.gen(function* () { + const prices = yield* Schema.decodeEffect(PriceResponse)({ + "ethereum-": { price: 3000 }, + "ethereum-0xabcd": { price: 1 }, + }); + const native = yield* Schema.decodeEffect(Token)({ + decimals: 18, + name: "Ether", + network: "ethereum", + symbol: "ETH", + }); + const addressed = yield* Schema.decodeEffect(Token)({ + address: "0xAbCd", + decimals: 6, + name: "USD Coin", + network: "ethereum", + symbol: "USDC", + }); + + expect(prices.getByToken(native)?.price).toEqual(exactDecimal(3000)); + expect(prices.getByToken(addressed)?.price).toEqual(exactDecimal(1)); + }) + ); +}); diff --git a/packages/widget/tests/domain/kyc.test.ts b/packages/widget/tests/domain/kyc.test.ts index f6ca6aaf8..25596b753 100644 --- a/packages/widget/tests/domain/kyc.test.ts +++ b/packages/widget/tests/domain/kyc.test.ts @@ -3,8 +3,9 @@ import { getKycProviderName, getKycUrl, mapKycStatusToGate, -} from "../../src/domain/types/kyc"; -import type { Yield } from "../../src/domain/types/yields"; +} from "../../src/domain/earn/kyc"; +import type { EarnYieldWithProvider } from "../../src/domain/earn/models"; + import { yieldApiProviderFixture, yieldApiYieldFixture } from "../fixtures"; const kycEligibility = { @@ -17,7 +18,9 @@ const kycEligibility = { subjectTypes: ["KYC"], } as const; -const createYield = (overrides?: Partial): Yield => +const createYield = ( + overrides?: Partial +): EarnYieldWithProvider => ({ ...yieldApiYieldFixture(), provider: yieldApiProviderFixture({ @@ -25,7 +28,7 @@ const createYield = (overrides?: Partial): Yield => website: "https://superstate.com", }), ...overrides, - }) as Yield; + }) as EarnYieldWithProvider; describe("KYC gate mapping", () => { it("allows approved and not required statuses", () => { diff --git a/packages/widget/tests/domain/network.test.ts b/packages/widget/tests/domain/network.test.ts new file mode 100644 index 000000000..7fe6630e6 --- /dev/null +++ b/packages/widget/tests/domain/network.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { BorrowNetwork } from "../../src/domain/borrow/network"; +import type { Network as NetworkContract } from "../../src/domain/network/contract"; +import { intersectNetworkLiterals } from "../../src/domain/network/intersection"; +import { isEvmNetwork, type Network } from "../../src/domain/network/network"; +import type { WalletNetwork } from "../../src/domain/wallet/network"; +import { + isCosmosWalletNetwork, + isEvmWalletNetwork, + isWalletNetwork, +} from "../../src/domain/wallet/network"; +import type { SKNetwork } from "../../src/public-api/types"; + +describe("Network", () => { + it("contains only network identities declared by both backends", () => { + expect( + intersectNetworkLiterals( + ["ethereum", "legacy-only", "shared"], + ["yield-only", "shared", "ethereum"] + ) + ).toEqual(["ethereum", "shared"]); + }); + + it("rejects an empty backend intersection", () => { + expect(() => + intersectNetworkLiterals(["legacy-only"], ["yield-only"]) + ).toThrow("Legacy and Yield Networks have no shared values"); + }); + + it("matches the private public transaction Network contract", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("limits Borrow Networks to Wallet Networks", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("distinguishes Wallet Networks from other common Networks", () => { + expect(isWalletNetwork("ethereum")).toBe(true); + expect(isWalletNetwork("cosmos")).toBe(true); + expect(isWalletNetwork("cardano")).toBe(true); + expect(isWalletNetwork("bittensor")).toBe(true); + expect(isWalletNetwork("robinhood")).toBe(true); + expect(isWalletNetwork("robinhood-testnet")).toBe(true); + expect(isWalletNetwork("plume")).toBe(false); + expect(isWalletNetwork("not-a-network")).toBe(false); + }); + + it("classifies wallet ecosystems without admitting non-wallet Networks", () => { + expect(isEvmWalletNetwork("ethereum")).toBe(true); + expect(isEvmWalletNetwork("robinhood")).toBe(true); + expect(isEvmWalletNetwork("robinhood-testnet")).toBe(true); + expect(isEvmWalletNetwork("plume")).toBe(false); + expect(isCosmosWalletNetwork("cosmos")).toBe(true); + expect(isCosmosWalletNetwork("evmos")).toBe(false); + }); + + it("derives EVM classification from the generated Legacy schema", () => { + expect(isEvmNetwork("ethereum")).toBe(true); + expect(isEvmNetwork("plume")).toBe(true); + expect(isEvmNetwork("cosmos")).toBe(false); + }); +}); diff --git a/packages/widget/tests/domain/position-derivations.test.ts b/packages/widget/tests/domain/position-derivations.test.ts new file mode 100644 index 000000000..86c83ff64 --- /dev/null +++ b/packages/widget/tests/domain/position-derivations.test.ts @@ -0,0 +1,213 @@ +import { Option, Schema } from "effect"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import type * as Atom from "effect/unstable/reactivity/Atom"; +import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; +import { describe, expect, it } from "vitest"; +import { EarnPosition } from "../../src/domain/earn/models"; +import { WalletAddress } from "../../src/domain/identity/identifiers"; +import { + getPositionBalances, + getPositionData, + hasActivePositionForYield, + toPositionBalancesByType, + toPositionsData, +} from "../../src/domain/portfolio/positions"; +import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; +import { toPositionItems } from "../../src/features/portfolio/state/read-models/positions"; +import { + PositionBalancesKey, + PositionDataKey, + positionBalancesAtom, + positionDataAtom, + yieldPositionsResourceAtom, +} from "../../src/resources/yield-positions/yield-positions"; +import { yieldApiYieldFixture, yieldBalanceFixture } from "../fixtures"; + +const makePosition = () => { + const yieldDto = yieldApiYieldFixture(); + + return Schema.decodeSync(EarnPosition)({ + balances: [ + yieldBalanceFixture({ + amount: "2", + amountUsd: "5", + type: "active", + token: yieldDto.token, + }), + yieldBalanceFixture({ + amount: "0", + amountUsd: "0", + type: "claimable", + token: yieldDto.token, + }), + ], + outputTokenBalance: null, + yieldId: yieldDto.id, + }); +}; + +describe("position derivations", () => { + it("normalizes positions and selects a requested or fallback balance group", () => { + const position = makePosition(); + const positions = toPositionsData([position]); + const selected = getPositionData(positions, position.yieldId); + const balances = getPositionBalances(selected, "missing-balance-group"); + + expect(selected?.yieldId).toBe(position.yieldId); + expect(balances?.balances).toHaveLength(2); + expect(balances?.type).toBe("default"); + expect(hasActivePositionForYield(positions, position.yieldId)).toBe(true); + expect( + hasActivePositionForYield( + positions, + yieldApiYieldFixture({ id: "missing-yield" }).id + ) + ).toBe(false); + }); + + it("groups non-zero balances and projects visible table rows", () => { + const position = makePosition(); + const positions = toPositionsData([position]); + const selected = getPositionBalances( + getPositionData(positions, position.yieldId), + "default" + ); + const byType = toPositionBalancesByType(selected?.balances ?? []); + const rows = toPositionItems(positions, false); + + expect(byType.get("active")?.[0]?.tokenPriceInUsd.toFixed()).toBe("5"); + expect(byType.has("claimable")).toBe(false); + expect(rows).toHaveLength(1); + expect(rows[0]?.balancesWithAmount).toHaveLength(1); + }); + + it("does not flag points-only claimable balances as actions", () => { + const yieldDto = yieldApiYieldFixture(); + const position = Schema.decodeSync(EarnPosition)({ + balances: [ + yieldBalanceFixture({ + amount: "2", + amountRaw: "2000000000000000000", + amountUsd: "5", + token: yieldDto.token, + type: "active", + }), + yieldBalanceFixture({ + amount: "1214.8591", + amountRaw: "12148591", + amountUsd: "0", + token: { + ...yieldDto.token, + isPoints: true, + symbol: "KelpDAO Miles", + }, + type: "claimable", + }), + ], + outputTokenBalance: null, + yieldId: yieldDto.id, + }); + + const [row] = toPositionItems(toPositionsData([position]), false); + + expect(row?.actionRequired).toBe(false); + }); + + it("deduplicates derived atom families by value-equal domain keys", () => { + const { yieldId } = makePosition(); + const scope = new WalletScopeKey({ + address: Schema.decodeSync(WalletAddress)( + "0x0000000000000000000000000000000000000001" + ), + network: "ethereum", + }); + + expect(positionDataAtom(new PositionDataKey({ scope, yieldId }))).toBe( + positionDataAtom(new PositionDataKey({ scope, yieldId })) + ); + }); + + it("resolves position balances for an explicit wallet scope", () => { + const position = makePosition(); + const scope = new WalletScopeKey({ + address: Schema.decodeSync(WalletAddress)( + "0x0000000000000000000000000000000000000001" + ), + network: "ethereum", + }); + const resource = yieldPositionsResourceAtom(scope); + const balances = positionBalancesAtom( + new PositionBalancesKey({ + balanceId: "default", + scope, + yieldId: position.yieldId, + }) + ); + const registry = AtomRegistry.make({ + initialValues: [ + [resource, AsyncResult.success({ errors: [], items: [position] })], + ], + }); + + expect( + Option.getOrNull(AsyncResult.value(registry.get(balances)))?.balances + ).toHaveLength(2); + }); + + it("retains same-wallet position data only while revalidating and clears it across owners or successful absence", () => { + const position = makePosition(); + const scopeA = new WalletScopeKey({ + address: Schema.decodeSync(WalletAddress)( + "0x0000000000000000000000000000000000000001" + ), + network: "ethereum", + }); + const scopeB = new WalletScopeKey({ + address: Schema.decodeSync(WalletAddress)( + "0x0000000000000000000000000000000000000002" + ), + network: "ethereum", + }); + const response = { errors: [], items: [position] }; + const resourceA = yieldPositionsResourceAtom(scopeA); + const resourceB = yieldPositionsResourceAtom(scopeB); + const selectedA = positionDataAtom( + new PositionDataKey({ scope: scopeA, yieldId: position.yieldId }) + ); + const selectedB = positionDataAtom( + new PositionDataKey({ scope: scopeB, yieldId: position.yieldId }) + ); + + const readA = (result: Atom.Type) => + AtomRegistry.make({ initialValues: [[resourceA, result]] }).get( + selectedA + ); + const readB = (result: Atom.Type) => + AtomRegistry.make({ initialValues: [[resourceB, result]] }).get( + selectedB + ); + + expect( + Option.getOrNull(AsyncResult.value(readA(AsyncResult.success(response)))) + ).not.toBeNull(); + + expect( + Option.getOrNull( + AsyncResult.value( + readA(AsyncResult.waiting(AsyncResult.success(response))) + ) + ) + ).not.toBeNull(); + + expect( + Option.getOrNull( + AsyncResult.value(readB(AsyncResult.success({ errors: [], items: [] }))) + ) + ).toBeNull(); + expect( + Option.getOrNull( + AsyncResult.value(readA(AsyncResult.success({ errors: [], items: [] }))) + ) + ).toBeNull(); + }); +}); diff --git a/packages/widget/tests/domain/price.test.ts b/packages/widget/tests/domain/price.test.ts new file mode 100644 index 000000000..c0ff74c44 --- /dev/null +++ b/packages/widget/tests/domain/price.test.ts @@ -0,0 +1,27 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { getTokenPriceInUSD } from "../../src/domain/finance/price"; +import { PriceResponse } from "../../src/domain/health/models"; +import { formatUsd } from "../../src/shared/lib/formatters"; + +describe("token USD prices", () => { + it("formats a native-token amount from the API price key", () => { + const token = { + network: "ethereum", + symbol: "ETH", + }; + const prices = Schema.decodeSync(PriceResponse)({ + "ethereum-undefined": { price: 1619.22, price_24_h: 0 }, + }); + + const amountInUsd = getTokenPriceInUSD({ + amount: "0.0012", + baseToken: null, + pricePerShare: null, + prices, + token, + }); + + expect(formatUsd(amountInUsd)).toBe("$1.94"); + }); +}); diff --git a/packages/widget/tests/domain/reward-rate.test.ts b/packages/widget/tests/domain/reward-rate.test.ts index 7c9e32026..a54026203 100644 --- a/packages/widget/tests/domain/reward-rate.test.ts +++ b/packages/widget/tests/domain/reward-rate.test.ts @@ -2,18 +2,22 @@ import { describe, expect, it } from "vitest"; import { getEffectiveYieldRewardRateDetails, getRewardRateBreakdown, -} from "../../src/domain/types/reward-rate"; -import type { RewardDto } from "../../src/generated/api/yield"; + type YieldRewardRate, +} from "../../src/domain/earn/reward-rate"; +import { exactDecimal } from "../../src/domain/finance/exact"; import { yieldApiValidatorFixture, yieldApiYieldFixture, yieldRewardRateFixture, } from "../fixtures"; +import { decodeValidator } from "../utils/validators"; const token = yieldApiYieldFixture().token; -const nativeComponent = (rate: number): RewardDto => ({ - rate, +const nativeComponent = ( + rate: number +): YieldRewardRate["components"][number] => ({ + rate: exactDecimal(rate), rateType: "APY", token, yieldSource: "staking", @@ -32,48 +36,56 @@ describe("getEffectiveYieldRewardRateDetails", () => { selectedValidators: new Map(), yieldDto, }) - ).toBe(rewardRate); + ).toBe(yieldDto.rewardRate); }); it("uses the selected validator reward rate", () => { const yieldDto = yieldApiYieldFixture({ rewardRate: yieldRewardRateFixture({ total: 0.1539 }), }); - const validator = yieldApiValidatorFixture({ - address: "validator-1", - rewardRate: yieldRewardRateFixture({ - total: 0.1582, - components: [nativeComponent(0.1582)], - }), - }); + const validator = decodeValidator( + yieldApiValidatorFixture({ + address: "validator-1", + rewardRate: yieldRewardRateFixture({ + total: 0.1582, + components: [nativeComponent(0.1582)], + }), + }) + ); const rewardRate = getEffectiveYieldRewardRateDetails({ selectedValidators: new Map([[validator.address, validator]]), yieldDto, }); - expect(rewardRate?.total).toBe(0.1582); - expect(getRewardRateBreakdown(rewardRate)[0]?.rate).toBe(0.1582); + expect(rewardRate?.total.isEqualTo("0.1582")).toBe(true); + expect( + getRewardRateBreakdown(rewardRate)[0]?.rate.isEqualTo("0.1582") + ).toBe(true); }); it("averages selected validator reward rates", () => { const yieldDto = yieldApiYieldFixture({ rewardRate: yieldRewardRateFixture({ total: 0.1539 }), }); - const firstValidator = yieldApiValidatorFixture({ - address: "validator-1", - rewardRate: yieldRewardRateFixture({ - total: 0.16, - components: [nativeComponent(0.16)], - }), - }); - const secondValidator = yieldApiValidatorFixture({ - address: "validator-2", - rewardRate: yieldRewardRateFixture({ - total: 0.18, - components: [nativeComponent(0.18)], - }), - }); + const firstValidator = decodeValidator( + yieldApiValidatorFixture({ + address: "validator-1", + rewardRate: yieldRewardRateFixture({ + total: 0.16, + components: [nativeComponent(0.16)], + }), + }) + ); + const secondValidator = decodeValidator( + yieldApiValidatorFixture({ + address: "validator-2", + rewardRate: yieldRewardRateFixture({ + total: 0.18, + components: [nativeComponent(0.18)], + }), + }) + ); const rewardRate = getEffectiveYieldRewardRateDetails({ selectedValidators: new Map([ @@ -83,7 +95,9 @@ describe("getEffectiveYieldRewardRateDetails", () => { yieldDto, }); - expect(rewardRate?.total).toBeCloseTo(0.17); - expect(getRewardRateBreakdown(rewardRate)[0]?.rate).toBeCloseTo(0.17); + expect(rewardRate?.total.isEqualTo("0.17")).toBe(true); + expect(getRewardRateBreakdown(rewardRate)[0]?.rate.isEqualTo("0.17")).toBe( + true + ); }); }); diff --git a/packages/widget/tests/domain/token.test.ts b/packages/widget/tests/domain/token.test.ts new file mode 100644 index 000000000..6d94921ce --- /dev/null +++ b/packages/widget/tests/domain/token.test.ts @@ -0,0 +1,41 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { equalTokens, Token, tokenString } from "../../src/domain/token/token"; + +const token = (overrides: Partial = {}) => + Schema.decodeSync(Token)({ + name: "USD Coin", + symbol: "USDC", + decimals: 6, + network: "ethereum", + address: "0xAbCd", + ...overrides, + }); + +describe("Token", () => { + it("uses network, exact symbol, and chain-aware address as identity", () => { + expect(tokenString(token())).toBe("ethereum-USDC-0xabcd"); + expect(equalTokens(token(), token({ address: "0xabCD" }))).toBe(true); + expect( + equalTokens( + token({ network: "robinhood", address: "0xAbCd" }), + token({ network: "robinhood", address: "0xabCD" }) + ) + ).toBe(true); + expect(equalTokens(token(), token({ symbol: "usdc" }))).toBe(false); + }); + + it("preserves non-EVM address case and distinguishes a missing address", () => { + const solana = token({ network: "solana", address: "AbCd" }); + + expect( + equalTokens(solana, token({ network: "solana", address: "abcd" })) + ).toBe(false); + expect( + equalTokens( + { network: "ethereum", symbol: "ETH" }, + { network: "ethereum", symbol: "ETH", address: "" } + ) + ).toBe(false); + }); +}); diff --git a/packages/widget/tests/domain/wallet-networks.test.ts b/packages/widget/tests/domain/wallet-networks.test.ts new file mode 100644 index 000000000..b79b192ea --- /dev/null +++ b/packages/widget/tests/domain/wallet-networks.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + EvmChainIds, + MiscChainIds, + SubstrateChainIds, + type SupportedSKChainIds, + type WalletNetwork as WalletNetworkContract, +} from "../../src/domain/wallet/contract"; +import { + getProtocolChainIdentity, + getWalletProtocolFamily, + getWalletRoutingId, + isWalletNetwork, + WalletNetwork, + walletCosmosNetworks, +} from "../../src/domain/wallet/network"; +import { + EvmChainIds as PublicEvmChainIds, + MiscChainIds as PublicMiscChainIds, + SubstrateChainIds as PublicSubstrateChainIds, + type SupportedSKChainIds as PublicSupportedSKChainIds, +} from "../../src/public-api/types"; +import { miscChainsMap } from "../../src/services/wallet/internal/adapters/configured-chains"; +import { getRegistryIdsToSKCosmosNetworks } from "../../src/services/wallet/internal/adapters/cosmos/chains/get-chain-registry"; +import { evmChainsMap } from "../../src/services/wallet/internal/adapters/evm/chains"; +import { substrateChainsMap } from "../../src/services/wallet/internal/adapters/substrate/chains"; + +const enumName = (network: string) => + network + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + +const adapterChainIds = ( + chains: Record +) => + Object.fromEntries( + Object.entries(chains).map(([network, chain]) => [ + enumName(network), + chain.wagmiChain.id, + ]) + ); + +const declaredChainIds = (chainIds: Record) => + Object.fromEntries( + Object.entries(chainIds).filter(([, value]) => typeof value === "number") + ); + +describe("Wallet Network chain IDs", () => { + it("publishes exhaustive wallet Domain chain-ID constants", () => { + expect(PublicEvmChainIds).toBe(EvmChainIds); + expect(PublicSubstrateChainIds).toBe(SubstrateChainIds); + expect(PublicMiscChainIds).toBe(MiscChainIds); + expect(EvmChainIds).not.toHaveProperty(String(EvmChainIds.Arbitrum)); + expect(SubstrateChainIds).not.toHaveProperty( + String(SubstrateChainIds.Polkadot) + ); + expect(MiscChainIds).not.toHaveProperty(String(MiscChainIds.Solana)); + expect(MiscChainIds).not.toHaveProperty("Stellar"); + expect(MiscChainIds).not.toHaveProperty("StellarTestnet"); + expectTypeOf< + Extract + >().toEqualTypeOf(); + expectTypeOf< + Extract + >().toEqualTypeOf(); + expectTypeOf< + Extract + >().toEqualTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf<42_162>().not.toMatchTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("projects routing and native identity facts from every Wallet Network", () => { + expectTypeOf().toEqualTypeOf(); + expect(WalletNetwork.literals).toHaveLength(70); + expect(WalletNetwork.literals).not.toContain("stellar-testnet"); + expect(isWalletNetwork("stellar-testnet")).toBe(false); + expect(getWalletRoutingId("arbitrum")).toBe(42_161); + expect(getProtocolChainIdentity("arbitrum")).toEqual({ + type: "evm", + chainId: 42_161, + }); + expect(getWalletRoutingId("cosmos")).toBe("cosmoshub-4"); + expect(getProtocolChainIdentity("cosmos")).toEqual({ + type: "cosmos", + chainId: "cosmoshub-4", + }); + expect(getWalletRoutingId("polkadot")).toBe(9999); + expect(getProtocolChainIdentity("polkadot")).toEqual({ + type: "substrate", + genesisHash: + "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + }); + expect(getWalletRoutingId("solana")).toBe(501); + expect(getProtocolChainIdentity("solana")).toEqual({ + type: "unmodelled", + }); + expect(getWalletProtocolFamily("solana")).toBe("solana"); + expect(getWalletRoutingId("stellar")).toBe(148); + expect(getProtocolChainIdentity("stellar")).toEqual({ + type: "stellar", + networkPassphrase: "Public Global Stellar Network ; September 2015", + }); + expect(getWalletProtocolFamily("stellar")).toBe("stellar"); + + expect( + WalletNetwork.literals.map((network) => getWalletProtocolFamily(network)) + ).not.toContain("misc"); + expect(walletCosmosNetworks).toContain("mantra"); + }); + + it("keeps EVM public IDs aligned with adapter metadata", () => { + expect(adapterChainIds(evmChainsMap)).toEqual( + declaredChainIds(EvmChainIds) + ); + }); + + it("keeps Substrate public IDs aligned with adapter metadata", () => { + expect(adapterChainIds(substrateChainsMap)).toEqual( + declaredChainIds(SubstrateChainIds) + ); + }); + + it("keeps miscellaneous public IDs aligned with adapter metadata", () => { + const publicIds = declaredChainIds(MiscChainIds); + const adapterIds = adapterChainIds(miscChainsMap); + + expect( + Object.fromEntries( + Object.entries(adapterIds).filter(([name]) => name in publicIds) + ) + ).toEqual(publicIds); + }); + + it("uses Wallet Routing IDs in adapter chain metadata", () => { + const adapterChains = { + ...evmChainsMap, + ...miscChainsMap, + ...substrateChainsMap, + }; + + for (const [network, configuration] of Object.entries(adapterChains)) { + expect(configuration.wagmiChain.id).toBe( + getWalletRoutingId(network as keyof typeof adapterChains) + ); + } + expect(miscChainsMap.solana.protocolFamily).toBe("solana"); + }); + + it("derives Cosmos registry reverse lookups from native identities", () => { + const registryIdsToNetworks = getRegistryIdsToSKCosmosNetworks(); + + expect(Object.keys(registryIdsToNetworks)).toHaveLength( + walletCosmosNetworks.length + ); + for (const network of walletCosmosNetworks) { + const identity = getProtocolChainIdentity(network); + expect(registryIdsToNetworks[identity.chainId]).toBe(network); + } + }); +}); diff --git a/packages/widget/tests/domain/yield-action-amount.test.ts b/packages/widget/tests/domain/yield-action-amount.test.ts new file mode 100644 index 000000000..f6d4eaf00 --- /dev/null +++ b/packages/widget/tests/domain/yield-action-amount.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { isYieldActionAmountEditable } from "../../src/domain/earn/stake"; +import { yieldApiYieldDtoFixture, yieldApiYieldFixture } from "../fixtures"; + +const yieldWithExitAmount = ({ + maximum, + minimum, + required, +}: { + readonly maximum?: string; + readonly minimum?: string; + readonly required: boolean; +}) => { + const base = yieldApiYieldDtoFixture(); + + return yieldApiYieldFixture({ + mechanics: { + ...base.mechanics, + arguments: { + ...base.mechanics.arguments, + exit: { + fields: [ + { + label: "Amount", + name: "amount", + required, + type: "string", + ...(maximum === undefined ? {} : { maximum }), + ...(minimum === undefined ? {} : { minimum }), + }, + ], + }, + }, + }, + }); +}; + +describe("isYieldActionAmountEditable", () => { + it("locks Exit when the yield does not advertise an amount argument", () => { + expect(isYieldActionAmountEditable(yieldApiYieldFixture(), "exit")).toBe( + false + ); + }); + + it("allows a partial Exit when amount is advertised but not required", () => { + expect( + isYieldActionAmountEditable( + yieldWithExitAmount({ required: false }), + "exit" + ) + ).toBe(true); + }); + + it("allows a partial Exit when amount is required", () => { + expect( + isYieldActionAmountEditable( + yieldWithExitAmount({ required: true }), + "exit" + ) + ).toBe(true); + }); + + it("locks Exit when amount is force-max", () => { + expect( + isYieldActionAmountEditable( + yieldWithExitAmount({ + maximum: "-1", + minimum: "-1", + required: true, + }), + "exit" + ) + ).toBe(false); + }); +}); diff --git a/packages/widget/tests/domain/yield-summaries.test.ts b/packages/widget/tests/domain/yield-summaries.test.ts index ae40c3ff7..3f36c4286 100644 --- a/packages/widget/tests/domain/yield-summaries.test.ts +++ b/packages/widget/tests/domain/yield-summaries.test.ts @@ -1,151 +1,30 @@ -import { describe, expect, it, vi } from "vitest"; -import { - fetchYieldSummariesByIds, - isVisibleYieldSummary, - type YieldSummary, -} from "../../src/hooks/api/use-yield-summaries"; -import type { ApiClient } from "../../src/providers/api/api-client"; -import { yieldApiYieldFixture, yieldRewardRateFixture } from "../fixtures"; - -const summary = (overrides?: Parameters[0]) => - yieldApiYieldFixture(overrides) as YieldSummary; - -describe("isVisibleYieldSummary", () => { - it("includes enterable, supported-chain, non-zero-reward summaries", () => { - expect(isVisibleYieldSummary(summary())).toBe(true); - }); - - it("excludes summaries that are not enterable", () => { - expect( - isVisibleYieldSummary(summary({ status: { enter: false, exit: true } })) - ).toBe(false); - }); - - it("excludes summaries with a zero reward rate", () => { - expect( - isVisibleYieldSummary( - summary({ rewardRate: yieldRewardRateFixture({ total: 0 }) }) - ) - ).toBe(false); - }); - - it("includes whitelisted zero-reward summaries", () => { - expect( - isVisibleYieldSummary( - summary({ - id: "optimism-usdc-gtusdcb-0x4ffc4e5f1f1f5c43dc9bc27b53728da13b02be35-4626-vault", - token: { - name: "USD Coin", - symbol: "USDC", - decimals: 6, - network: "optimism", - }, - rewardRate: yieldRewardRateFixture({ total: 0 }), - }) - ) - ).toBe(true); - }); -}); - -describe("fetchYieldSummariesByIds", () => { - it("splits yield IDs into bounded chunks", async () => { - const items = Array.from({ length: 5 }, (_, index) => - summary({ id: `yield-${index}` }) - ); - - const getYields = vi.fn( - async ({ params }: { params: { yieldIds: ReadonlyArray } }) => ({ - total: params.yieldIds.length, - offset: 0, - limit: params.yieldIds.length, - items: params.yieldIds.flatMap( - (yieldId) => items.find((item) => item.id === yieldId) ?? [] - ), - }) - ); - - const apiClient = { - withOptions: () => ({ yield: { YieldsControllerGetYields: getYields } }), - } as unknown as ApiClient; - - const result = await fetchYieldSummariesByIds({ - apiClient, - chunkSize: 2, - yieldIds: items.map((item) => item.id), - }); - - expect(result.map((item) => item.id)).toEqual([ - "yield-0", - "yield-1", - "yield-2", - "yield-3", - "yield-4", - ]); - expect(getYields).toHaveBeenCalledTimes(3); - expect(getYields.mock.calls.map(([arg]) => arg.params.yieldIds)).toEqual([ - ["yield-0", "yield-1"], - ["yield-2", "yield-3"], - ["yield-4"], - ]); - }); - - it("uses a single request when IDs fit within the chunk size", async () => { - const getYields = vi.fn(async () => ({ - total: 2, - offset: 0, - limit: 2, - items: [summary({ id: "yield-0" }), summary({ id: "yield-1" })], - })); - - const apiClient = { - withOptions: () => ({ yield: { YieldsControllerGetYields: getYields } }), - } as unknown as ApiClient; - - await fetchYieldSummariesByIds({ - apiClient, - chunkSize: 2, - yieldIds: ["yield-0", "yield-1"], - }); - - expect(getYields).toHaveBeenCalledTimes(1); - expect(getYields).toHaveBeenCalledWith({ - params: { - yieldIds: ["yield-0", "yield-1"], - limit: 2, - }, - }); - }); - - it("deduplicates requested IDs and returns summaries in first occurrence order", async () => { - const getYields = vi.fn( - async ({ params }: { params: { yieldIds: ReadonlyArray } }) => ({ - total: params.yieldIds.length, - offset: 0, - limit: params.yieldIds.length, - items: [...params.yieldIds] - .reverse() - .map((yieldId) => summary({ id: yieldId })), +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { EarnYieldPage } from "../../src/domain/earn/models"; +import { yieldApiYieldDtoFixture } from "../fixtures"; + +describe("yield summary atom boundary", () => { + it.effect( + "retains valid summaries when a top-level sibling is malformed", + () => + Effect.gen(function* () { + const valid = yieldApiYieldDtoFixture({ + id: "yield-valid", + prime: false, + }); + const invalid: Record = { + ...yieldApiYieldDtoFixture({ id: "yield-invalid", prime: false }), + token: { ...valid.token, decimals: "invalid" }, + }; + const page = yield* Schema.decodeEffect(EarnYieldPage)({ + items: [valid, invalid], + limit: 2, + offset: 0, + total: 2, + }); + + expect(page.items?.map((item) => item.id)).toEqual(["yield-valid"]); + expect(page.total).toBe(2); }) - ); - - const apiClient = { - withOptions: () => ({ yield: { YieldsControllerGetYields: getYields } }), - } as unknown as ApiClient; - - const result = await fetchYieldSummariesByIds({ - apiClient, - chunkSize: 2, - yieldIds: ["yield-2", "yield-1", "yield-2", "yield-0"], - }); - - expect(result.map((item) => item.id)).toEqual([ - "yield-2", - "yield-1", - "yield-0", - ]); - expect(getYields.mock.calls.map(([arg]) => arg.params.yieldIds)).toEqual([ - ["yield-2", "yield-1"], - ["yield-0"], - ]); - }); + ); }); diff --git a/packages/widget/tests/features/activity-action-details.test.ts b/packages/widget/tests/features/activity-action-details.test.ts new file mode 100644 index 000000000..6d926d8e8 --- /dev/null +++ b/packages/widget/tests/features/activity-action-details.test.ts @@ -0,0 +1,115 @@ +import { DateTime } from "effect"; +import { describe, expect, it } from "vitest"; +import type { ActivityActionItem } from "../../src/features/activity/model/activity-action"; +import { projectActivityActionDetails } from "../../src/features/activity/model/activity-action-details"; +import { + yieldApiActionFixture, + yieldApiTransactionFixture, + yieldApiYieldFixture, +} from "../fixtures"; + +const makeItem = ( + overrides: Parameters[0] = {}, + withYield = true +): ActivityActionItem => { + const yieldData = yieldApiYieldFixture(); + return { + actionData: yieldApiActionFixture({ + yieldId: yieldData.id, + ...overrides, + }), + validatorsData: [], + walletScope: {} as never, + yieldData: withYield ? yieldData : null, + }; +}; + +const presentationTime = { + now: DateTime.makeUnsafe("2026-08-27T10:00:00.000Z"), + timeZone: DateTime.zoneMakeNamedUnsafe("UTC"), +}; + +describe("Activity action details projection", () => { + it("projects an exact receipt and keeps backend transaction order", () => { + const first = yieldApiTransactionFixture({ + id: "transaction-b", + status: "FAILED", + title: "Stake", + }); + const second = yieldApiTransactionFixture({ + id: "transaction-a", + status: "SKIPPED", + title: "Approval", + }); + const view = projectActivityActionDetails({ + item: makeItem({ + amount: "1000000000000000.123456789", + createdAt: DateTime.makeUnsafe("2026-08-26T10:00:00.000Z"), + status: "FAILED", + transactions: [first, second], + type: "UNSTAKE", + }), + locale: "en-US", + presentationTime, + }); + + expect(view).toMatchObject({ + amount: "-1000000000000000.123456789", + canContinue: false, + statusLabel: "failed", + transactions: [ + { id: "transaction-b", title: "Stake" }, + { id: "transaction-a", title: "Approval" }, + ], + }); + }); + + it("presents an expired waiting action without Continue", () => { + const view = projectActivityActionDetails({ + item: makeItem({ + createdAt: DateTime.makeUnsafe("2026-08-20T10:00:00.000Z"), + status: "WAITING_FOR_NEXT", + }), + locale: "en-US", + presentationTime, + }); + + expect(view.statusLabel).toBe("expired"); + expect(view.canContinue).toBe(false); + }); + + it("keeps an action-only receipt when Yield metadata is unavailable", () => { + const view = projectActivityActionDetails({ + item: makeItem( + { + createdAt: DateTime.makeUnsafe("2026-08-27T09:00:00.000Z"), + status: "WAITING_FOR_NEXT", + }, + false + ), + locale: "en-US", + presentationTime, + }); + + expect(view.canContinue).toBe(false); + expect(view.continuationUnavailable).toBe(true); + }); + + it("presents an expired waiting action when Yield metadata is unavailable", () => { + const view = projectActivityActionDetails({ + item: makeItem( + { + createdAt: DateTime.makeUnsafe("2026-08-20T10:00:00.000Z"), + status: "WAITING_FOR_NEXT", + }, + false + ), + locale: "en-US", + presentationTime, + }); + + expect(view.statusLabel).toBe("expired"); + expect(view.canContinue).toBe(false); + expect(view.continuationUnavailable).toBe(false); + }); +}); diff --git a/packages/widget/tests/features/activity-action-list-item.test.ts b/packages/widget/tests/features/activity-action-list-item.test.ts new file mode 100644 index 000000000..2420f4cd7 --- /dev/null +++ b/packages/widget/tests/features/activity-action-list-item.test.ts @@ -0,0 +1,279 @@ +import { DateTime, Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { + getActivityActionCapabilities, + isActivityActionOwnedByScope, + isContinuableYieldAction, +} from "../../src/domain/activity/action-capabilities"; +import { WalletAddress } from "../../src/domain/identity/identifiers"; +import { WalletScopeOwnerKey } from "../../src/domain/wallet/wallet-scope"; +import type { ActivityActionItem } from "../../src/features/activity/model/activity-action"; +import { projectActivityActionListItem } from "../../src/features/activity/model/activity-action-list-item"; +import { yieldApiActionFixture, yieldApiYieldFixture } from "../fixtures"; + +const makeItem = ( + actionData: ActivityActionItem["actionData"], + yieldData: ActivityActionItem["yieldData"] +): ActivityActionItem => ({ + actionData, + validatorsData: [], + walletScope: {} as never, + yieldData, +}); + +describe("Activity action list item projection", () => { + it.each([ + ["SUCCESS", true], + ["FAILED", true], + ["WAITING_FOR_NEXT", true], + ["CREATED", false], + ["PROCESSING", false], + ["CANCELED", false], + ["STALE", false], + ] as const)( + "projects the %s Activity capabilities", + (status, visibleInFeed) => { + expect(getActivityActionCapabilities(status)).toEqual({ + visibleInFeed, + }); + } + ); + + it("projects domain and time values without React", () => { + const yieldData = yieldApiYieldFixture(); + const projection = projectActivityActionListItem({ + action: makeItem( + yieldApiActionFixture({ + createdAt: DateTime.makeUnsafe("2026-07-28T08:00:00.000Z"), + status: "FAILED", + type: "STAKE", + yieldId: yieldData.id, + }), + yieldData + ), + locale: "en-US", + presentationTime: { + now: DateTime.makeUnsafe("2026-07-28T10:00:00.000Z"), + timeZone: DateTime.zoneMakeNamedUnsafe("UTC"), + }, + }); + + expect(projection).toMatchObject({ + amountSign: "", + canOpenDetails: true, + direction: "deposit", + isPositive: true, + timestamp: { + dayKind: "today", + relative: { unit: "hours", value: 2 }, + }, + title: { + _tag: "deposited", + tokenSymbol: yieldData.token.symbol, + }, + tokenSymbol: yieldData.token.symbol, + }); + }); + + it("allows continuation before, but not at, the 168 hour cutoff", () => { + const action = yieldApiActionFixture({ + createdAt: DateTime.makeUnsafe("2026-08-20T10:00:00.000Z"), + status: "WAITING_FOR_NEXT", + }); + + expect( + isContinuableYieldAction( + action, + DateTime.makeUnsafe("2026-08-27T09:59:59.999Z") + ) + ).toBe(true); + expect( + isContinuableYieldAction( + action, + DateTime.makeUnsafe("2026-08-27T10:00:00.000Z") + ) + ).toBe(false); + }); + + it("rejects a same-address action when its Yield belongs to another network", () => { + const action = yieldApiActionFixture(); + const scope = new WalletScopeOwnerKey({ + address: action.address, + network: "ethereum", + }); + + expect( + isActivityActionOwnedByScope({ + action, + scope, + yieldData: yieldApiYieldFixture({ network: "polygon" }), + }) + ).toBe(false); + expect( + isActivityActionOwnedByScope({ action, scope, yieldData: null }) + ).toBe(true); + }); + + it("matches EVM action owners case-insensitively", () => { + const action = yieldApiActionFixture({ + address: Schema.decodeSync(WalletAddress)( + "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" + ), + }); + const scope = new WalletScopeOwnerKey({ + address: Schema.decodeSync(WalletAddress)( + "0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd" + ), + network: "ethereum", + }); + + expect( + isActivityActionOwnedByScope({ action, scope, yieldData: null }) + ).toBe(true); + }); + + it("projects an unavailable action without inventing a token", () => { + const projection = projectActivityActionListItem({ + action: makeItem( + yieldApiActionFixture({ + createdAt: undefined, + type: "VOTE", + }), + null + ), + locale: "en-US", + presentationTime: null, + }); + + expect(projection).toMatchObject({ + canOpenDetails: true, + title: { + _tag: "generic", + actionLabel: "Vote", + tokenSymbol: null, + }, + tokenSymbol: null, + }); + }); + + it.each([ + "", + " ", + "0x", + "0X0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000001", + ])( + "projects an action while omitting an unusable raw token value (%s)", + (inputToken) => { + const projection = projectActivityActionListItem({ + action: makeItem( + yieldApiActionFixture({ + rawArguments: { inputToken }, + type: "VOTE", + }), + null + ), + locale: "en-US", + presentationTime: null, + }); + + expect(projection.tokenSymbol).toBeNull(); + } + ); + + it("keeps a readable raw token for an unavailable yield", () => { + const projection = projectActivityActionListItem({ + action: makeItem( + yieldApiActionFixture({ + rawArguments: { inputToken: "POL" }, + type: "VOTE", + }), + null + ), + locale: "en-US", + presentationTime: null, + }); + + expect(projection.tokenSymbol).toBe("POL"); + }); + + it.each(["CANCELED", "STALE"] as const)( + "allows direct details for a %s action", + (status) => { + const yieldData = yieldApiYieldFixture(); + const projection = projectActivityActionListItem({ + action: makeItem( + yieldApiActionFixture({ + status, + yieldId: yieldData.id, + }), + yieldData + ), + locale: "en-US", + presentationTime: null, + }); + + expect(projection.canOpenDetails).toBe(true); + } + ); + + it.each([ + ["SUCCESS", "completed"], + ["FAILED", "failed"], + ["WAITING_FOR_NEXT", "action-required"], + ] as const)("projects the %s status label", (status, statusLabel) => { + const yieldData = yieldApiYieldFixture(); + const projection = projectActivityActionListItem({ + action: makeItem( + yieldApiActionFixture({ status, yieldId: yieldData.id }), + yieldData + ), + locale: "en-US", + presentationTime: null, + }); + + expect(projection.statusLabel).toBe(statusLabel); + }); + + it.each(["WITHDRAW_REQUEST", "INSTANT_WITHDRAW"] as const)( + "projects %s as an outbound withdrawal", + (type) => { + const projection = projectActivityActionListItem({ + action: makeItem( + yieldApiActionFixture({ amount: "1", type }), + yieldApiYieldFixture() + ), + locale: "en-US", + presentationTime: null, + }); + + expect(projection).toMatchObject({ + amountSign: "-", + direction: "withdraw", + isPositive: false, + }); + } + ); + + it.each([ + "VOTE", + "REVOKE", + "VOTE_LOCKED", + "REVOTE", + "MIGRATE", + "VERIFY_WITHDRAW_CREDENTIALS", + "DELEGATE", + ] as const)("projects %s as neutral", (type) => { + const projection = projectActivityActionListItem({ + action: makeItem(yieldApiActionFixture({ type }), yieldApiYieldFixture()), + locale: "en-US", + presentationTime: null, + }); + + expect(projection).toMatchObject({ + amountSign: "", + direction: "neutral", + isPositive: false, + }); + }); +}); diff --git a/packages/widget/tests/features/activity-dashboard-split.dom.test.tsx b/packages/widget/tests/features/activity-dashboard-split.dom.test.tsx new file mode 100644 index 000000000..d5821e3af --- /dev/null +++ b/packages/widget/tests/features/activity-dashboard-split.dom.test.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ActivityPageView } from "../../src/features/activity/state/page"; +import { ActivityTabPage } from "../../src/features/activity/ui/dashboard/activity"; +import { render } from "../utils/test-utils.dom"; + +const wallet = vi.hoisted(() => ({ + current: null as { readonly status: string } | null, +})); +const pageView = vi.hoisted(() => ({ + current: { status: "empty" } as ActivityPageView, +})); + +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), + Outlet: () =>
, +})); +vi.mock( + "../../src/features/activity/ui/activity-page/activity-page-content", + () => ({ + ActivityPageContent: () =>
, + }) +); +vi.mock("../../src/features/wallet/index", () => ({ + useSKWallet: () => wallet.current, +})); +vi.mock("@effect/atom-react", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: () => pageView.current, +})); +vi.mock("../../src/features/widget-shell/views", () => ({ + AnimationPage: ({ children }: { readonly children: ReactNode }) => children, + BackButtonProvider: ({ children }: { readonly children: ReactNode }) => + children, +})); + +const renderTab = (initialPath = "/activity") => + render( + + + + ); + +describe("Dashboard Activity split view", () => { + beforeEach(() => { + wallet.current = { status: "connected" }; + pageView.current = { + actions: [{ actionData: { id: "a1" } } as never], + filterOptions: [{ count: 1, filter: "all" }], + pagination: { status: "complete" }, + refreshStatus: "fresh", + selectedFilter: "all", + showingCount: 1, + status: "ready", + total: 1, + }; + }); + + it("keeps the feed mounted and renders the details outlet when there is activity", async () => { + const app = await renderTab(); + + expect(app.container.querySelector('[data-rk="activity-feed"]')).not.toBe( + null + ); + expect( + app.container.querySelector('[data-rk="activity-details-outlet"]') + ).not.toBe(null); + expect( + app.container.querySelector('[data-rk="activity-details-panel"]') + ).not.toBe(null); + }); + + it("renders execution without the details inset chrome", async () => { + const app = await renderTab("/activity/a1/steps"); + + expect( + app.container.querySelector('[data-rk="activity-details-panel"]') + ).toBe(null); + expect( + app.container.querySelector('[data-rk="activity-execution-panel"]') + ).not.toBe(null); + }); + + it("shows the feed only when the wallet is not connected", async () => { + wallet.current = { status: "disconnected" }; + const app = await renderTab(); + + expect(app.container.querySelector('[data-rk="activity-feed"]')).not.toBe( + null + ); + expect( + app.container.querySelector('[data-rk="activity-details-outlet"]') + ).toBe(null); + }); + + it("shows the feed only when there is no previous activity", async () => { + pageView.current = { status: "empty" }; + const app = await renderTab(); + + expect(app.container.querySelector('[data-rk="activity-feed"]')).not.toBe( + null + ); + expect( + app.container.querySelector('[data-rk="activity-details-outlet"]') + ).toBe(null); + }); +}); diff --git a/packages/widget/tests/features/activity-details.dom.test.tsx b/packages/widget/tests/features/activity-details.dom.test.tsx new file mode 100644 index 000000000..62942b82e --- /dev/null +++ b/packages/widget/tests/features/activity-details.dom.test.tsx @@ -0,0 +1,122 @@ +import { DateTime } from "effect"; +import { act } from "react"; +import { I18nextProvider } from "react-i18next"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import type { ActivityActionItem } from "../../src/features/activity/model/activity-action"; +import { ActivityDetailsPage } from "../../src/features/activity/ui/activity-details/activity-details.page"; +import { createWidgetI18nInstance } from "../../src/services/translation/widget-translation"; +import { + yieldApiActionFixture, + yieldApiTransactionFixture, + yieldApiYieldFixture, +} from "../fixtures"; +import { render } from "../utils/test-utils.dom"; + +const route = vi.hoisted(() => ({ current: {} as Record })); +const trackEvent = vi.hoisted(() => vi.fn()); +const trackPage = vi.hoisted(() => vi.fn()); +const atomValue = vi.hoisted(() => ({ current: null as unknown })); +const presentationTime = { + now: DateTime.makeUnsafe("2026-08-27T10:00:00.000Z"), + timeZone: DateTime.zoneMakeNamedUnsafe("UTC"), +}; + +vi.mock("@effect/atom-react", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: () => atomValue.current, +})); +vi.mock("../../src/features/activity/react/activity-action-route", () => ({ + useActivityActionRoute: () => route.current, +})); +vi.mock("../../src/features/tracking/index", () => ({ + useTrackEvent: () => trackEvent, + useTrackPage: (page: string) => trackPage(page), +})); + +const i18n = createWidgetI18nInstance(); + +const makeItem = ({ + status = "FAILED", + withYield = true, +}: { + readonly status?: "FAILED" | "WAITING_FOR_NEXT"; + readonly withYield?: boolean; +} = {}): ActivityActionItem => { + const yieldData = yieldApiYieldFixture(); + return { + actionData: yieldApiActionFixture({ + amount: "1000000000000000.123456789", + createdAt: DateTime.makeUnsafe("2026-08-26T10:00:00.000Z"), + status, + transactions: [ + yieldApiTransactionFixture({ + error: "Execution reverted", + explorerUrl: "https://explorer.test/failed", + status: "FAILED", + title: "Stake", + }), + ], + type: "UNSTAKE", + yieldId: yieldData.id, + }), + validatorsData: [], + walletScope: { network: "ethereum" } as never, + yieldData: withYield ? yieldData : null, + }; +}; + +const renderDetails = async () => { + atomValue.current = presentationTime; + return render( + + + + + + ); +}; + +describe("Activity details receipt", () => { + it("renders exact historical action data and opens a transaction link", async () => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + route.current = { + continuationReady: false, + item: makeItem(), + presentation: "Dashboard", + providersDetails: [{ name: "StakeKit" }], + }; + + const app = await renderDetails(); + const explorer = Array.from(app.container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("View transaction") + ); + + expect(app.container.textContent).toContain("-1000000000000000.123456789"); + expect(app.container.textContent).toContain("Failed"); + expect(app.container.textContent).toContain("Execution reverted"); + expect(app.container.textContent).not.toContain("Continue"); + + await act(async () => explorer?.click()); + expect(open).toHaveBeenCalledWith("https://explorer.test/failed", "_blank"); + expect(trackEvent).toHaveBeenCalledWith("viewTxClicked"); + expect(trackPage).toHaveBeenCalledWith("unstakeReview"); + open.mockRestore(); + }); + + it("keeps the receipt available when Yield metadata is missing", async () => { + route.current = { + continuationReady: false, + item: makeItem({ status: "WAITING_FOR_NEXT", withYield: false }), + presentation: "Classic", + providersDetails: [], + }; + + const app = await renderDetails(); + + expect(app.container.textContent).toContain( + "This action cannot continue because its yield details are unavailable." + ); + expect(app.container.textContent).not.toContain("Continue"); + }); +}); diff --git a/packages/widget/tests/features/activity-page-commands.test.ts b/packages/widget/tests/features/activity-page-commands.test.ts new file mode 100644 index 000000000..5b8c63dac --- /dev/null +++ b/packages/widget/tests/features/activity-page-commands.test.ts @@ -0,0 +1,356 @@ +import { Effect, Layer, Schema } from "effect"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; +import { describe, expect, it, vi } from "vitest"; +import { appRuntime } from "../../src/app/runtime/app-runtime"; +import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; +import type { ActivityFilterOption } from "../../src/features/activity/model/filters"; +import { + activityPageViewAtom, + loadMoreActivityAtom, + retryActivityPageAtom, + setActivityPageFilterAtom, +} from "../../src/features/activity/state/page"; +import { + ActivityActionsKey, + ActivityFilterOptionsKey, + activityActionsPullAtom, + activityFilterAtom, + activityFilterOptionsAtom, + loadMoreActivityActionsAtom, +} from "../../src/features/activity/state/page-resources"; +import { + walletConfigResultAtom, + walletConnectionStateAtom, + walletScopeAtom, +} from "../../src/features/wallet/index"; +import { YieldResourceSource } from "../../src/services/api/resource-sources"; +import { yieldApiActionFixture, yieldApiYieldFixture } from "../fixtures"; + +const address = Schema.decodeSync( + Schema.NonEmptyString.pipe(Schema.brand("WalletAddress")) +)("0x0000000000000000000000000000000000000001"); +const walletScope = new WalletScopeKey({ + address, + network: "ethereum", +}); +const connectedWallet = { + additionalAddresses: null, + address, + chain: {} as never, + connector: {} as never, + connectorChains: [], + isLedgerLive: false, + isLedgerLiveAccountPlaceholder: false, + ledgerAccounts: [], + network: "ethereum" as const, + status: "connected" as const, +}; +const filterOptionsAtom = activityFilterOptionsAtom( + new ActivityFilterOptionsKey({ scope: walletScope }) +); + +type ListActivity = YieldResourceSource["Service"]["listActivity"]; + +const makeRegistry = ( + listActivity: ListActivity, + filterOptions: ActivityFilterOption[] = [] +) => + AtomRegistry.make({ + initialValues: [ + Atom.initialValue( + appRuntime.layer, + Layer.succeed( + YieldResourceSource, + YieldResourceSource.of({ + getOpportunity: () => Effect.succeed(yieldApiYieldFixture()), + getProvider: () => Effect.succeedNone, + listActivity, + } as never) + ) + ), + Atom.initialValue(filterOptionsAtom, AsyncResult.success(filterOptions)), + Atom.initialValue( + walletConfigResultAtom, + AsyncResult.success({} as never) + ), + Atom.initialValue(walletConnectionStateAtom, connectedWallet), + Atom.initialValue(walletScopeAtom, walletScope), + ], + }); + +describe("Activity page commands", () => { + it("updates the selected filter through a command Atom", () => { + const registry = AtomRegistry.make(); + + try { + expect(registry.get(activityFilterAtom)).toBe("all"); + + registry.set(setActivityPageFilterAtom, "defi"); + + expect(registry.get(activityFilterAtom)).toBe("defi"); + } finally { + registry.dispose(); + } + }); + + it("prefetches every non-empty category when Activity is observed", async () => { + const listActivity = vi.fn((request) => + Effect.succeed({ + items: [], + limit: request.limit ?? 50, + offset: request.offset ?? 0, + total: 2, + }) + ); + const registry = makeRegistry(listActivity, [ + { count: 2, filter: "all" }, + { count: 2, filter: "stake" }, + ]); + const unmount = registry.mount(activityPageViewAtom); + + try { + await vi.waitFor(() => { + const historyRequests = listActivity.mock.calls + .map(([request]) => request) + .filter((request) => request.limit === 50); + + expect( + historyRequests.some((request) => request.yieldTypes === undefined) + ).toBe(true); + expect( + historyRequests.some((request) => request.yieldTypes !== undefined) + ).toBe(true); + }); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("discards the selected filter when its observing surface is released", async () => { + const registry = AtomRegistry.make(); + const unmount = registry.mount(activityFilterAtom); + + registry.set(setActivityPageFilterAtom, "defi"); + expect(registry.get(activityFilterAtom)).toBe("defi"); + + unmount(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(registry.get(activityFilterAtom)).toBe("all"); + registry.dispose(); + }); + + it("loads the next page only through the current ready page resource", async () => { + const yieldModel = yieldApiYieldFixture(); + const actions = Array.from({ length: 51 }, (_, index) => + yieldApiActionFixture({ + id: `activity-${index}`, + yieldId: yieldModel.id, + }) + ); + const nextPage = { shouldFail: true }; + const listActivity = vi.fn((request) => { + const limit = request.limit ?? 50; + const offset = request.offset ?? 0; + if (offset > 0 && nextPage.shouldFail) { + return Effect.fail({} as never); + } + + return Effect.succeed({ + items: actions.slice(offset, offset + limit), + limit, + offset, + total: actions.length, + }); + }); + const registry = makeRegistry(listActivity); + const unmount = registry.mount(activityPageViewAtom); + + try { + await vi.waitFor(() => + expect(registry.get(activityPageViewAtom)).toMatchObject({ + pagination: { status: "idle" }, + showingCount: 50, + status: "ready", + }) + ); + + registry.set(loadMoreActivityAtom, undefined); + + await vi.waitFor(() => + expect(registry.get(activityPageViewAtom)).toMatchObject({ + pagination: { status: "load-more-failed" }, + showingCount: 50, + status: "ready", + }) + ); + + nextPage.shouldFail = false; + registry.set(loadMoreActivityAtom, undefined); + + await vi.waitFor(() => + expect(registry.get(activityPageViewAtom)).toMatchObject({ + pagination: { status: "complete" }, + showingCount: 51, + status: "ready", + }) + ); + expect( + listActivity.mock.calls.filter( + ([request]) => request.limit === 50 && request.offset === 50 + ) + ).toHaveLength(2); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("refreshes failed history and filter options from the page retry", async () => { + const yieldModel = yieldApiYieldFixture(); + const action = yieldApiActionFixture({ yieldId: yieldModel.id }); + const history = { shouldFail: true }; + const listActivity = vi.fn((request) => { + const limit = request.limit ?? 50; + const offset = request.offset ?? 0; + + if (limit === 1) { + return Effect.succeed({ + items: [], + limit, + offset, + total: 0, + }); + } + if (history.shouldFail) return Effect.fail({} as never); + + return Effect.succeed({ + items: [action], + limit, + offset, + total: 1, + }); + }); + const registry = makeRegistry(listActivity); + const unmount = registry.mount(activityPageViewAtom); + + try { + await vi.waitFor(() => + expect(registry.get(activityPageViewAtom)).toEqual({ + status: "failed", + }) + ); + + history.shouldFail = false; + registry.set(retryActivityPageAtom, undefined); + + await vi.waitFor(() => + expect(registry.get(activityPageViewAtom)).toMatchObject({ + actions: [{ actionData: { id: action.id } }], + status: "ready", + }) + ); + expect( + listActivity.mock.calls.some(([request]) => request.limit === 1) + ).toBe(true); + } finally { + unmount(); + registry.dispose(); + } + }); +}); + +describe("Activity actions resource commands", () => { + it("retries the failed offset after multiple successful pages", async () => { + const yieldModel = yieldApiYieldFixture(); + const actions = Array.from({ length: 101 }, (_, index) => + yieldApiActionFixture({ + id: `activity-${index}`, + yieldId: yieldModel.id, + }) + ); + const nextPage = { shouldFail: true }; + const listActivity = vi.fn((request) => { + const limit = request.limit ?? 50; + const offset = request.offset ?? 0; + if (offset === 100 && nextPage.shouldFail) { + return Effect.fail({} as never); + } + + return Effect.succeed({ + items: actions.slice(offset, offset + limit), + limit, + offset, + total: actions.length, + }); + }); + const key = new ActivityActionsKey({ + filter: "all", + scope: walletScope, + }); + const actionsAtom = activityActionsPullAtom(key); + const loadMoreAtom = loadMoreActivityActionsAtom(key); + const registry = makeRegistry(listActivity); + const unmountActions = registry.mount(actionsAtom); + + try { + await vi.waitFor(() => + expect(registry.get(actionsAtom)).toMatchObject({ + _tag: "Success", + value: { done: false }, + }) + ); + + registry.set(loadMoreAtom, undefined); + + await vi.waitFor(() => + expect(registry.get(actionsAtom)).toMatchObject({ + _tag: "Success", + value: { + done: false, + items: [ + { actions: expect.any(Array) }, + { actions: expect.any(Array) }, + ], + }, + }) + ); + + registry.set(loadMoreAtom, undefined); + + await vi.waitFor(() => + expect(registry.get(actionsAtom)).toMatchObject({ + _tag: "Failure", + }) + ); + + nextPage.shouldFail = false; + registry.set(loadMoreAtom, undefined); + + await vi.waitFor(() => + expect(registry.get(actionsAtom)).toMatchObject({ + _tag: "Success", + value: { + done: true, + items: [ + { actions: expect.any(Array) }, + { actions: expect.any(Array) }, + { actions: expect.any(Array) }, + ], + }, + }) + ); + expect( + listActivity.mock.calls.filter( + ([request]) => request.limit === 50 && request.offset === 100 + ) + ).toHaveLength(2); + } finally { + unmountActions(); + registry.dispose(); + } + }); +}); diff --git a/packages/widget/tests/features/activity-page-view.test.ts b/packages/widget/tests/features/activity-page-view.test.ts new file mode 100644 index 000000000..804e367e7 --- /dev/null +++ b/packages/widget/tests/features/activity-page-view.test.ts @@ -0,0 +1,433 @@ +import { Cause, Option, Schema } from "effect"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { describe, expect, it } from "vitest"; +import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; +import type { ActivityActionItem } from "../../src/features/activity/model/activity-action"; +import { + projectActivityPageView, + resolveActivityPageFilter, + resolveActivityPageWalletStatus, + shouldShowActivityDashboardSplit, +} from "../../src/features/activity/state/page"; +import { yieldApiActionFixture, yieldApiYieldFixture } from "../fixtures"; + +type ActivityPageProjectionInput = Parameters< + typeof projectActivityPageView +>[0]; +type ActivityActionsResult = ActivityPageProjectionInput["actionsResult"]; +type ActivityFilterOptionsResult = + ActivityPageProjectionInput["filterOptionsResult"]; +type AsyncValue = + Result extends AsyncResult.AsyncResult + ? Value + : never; +type ActivityActionsValue = AsyncValue; +type ActivityFilterOptionsValue = AsyncValue; + +const walletScope = new WalletScopeKey({ + address: Schema.decodeSync( + Schema.NonEmptyString.pipe(Schema.brand("WalletAddress")) + )("0x0000000000000000000000000000000000000001"), + network: "ethereum", +}); + +const makeActionItem = (id: string): ActivityActionItem => { + const yieldModel = yieldApiYieldFixture(); + + return { + actionData: yieldApiActionFixture({ id, yieldId: yieldModel.id }), + validatorsData: [], + walletScope, + yieldData: yieldModel, + }; +}; + +const makeUnavailableActionItem = (id: string): ActivityActionItem => ({ + actionData: yieldApiActionFixture({ id, type: "VOTE" }), + validatorsData: [], + walletScope, + yieldData: null, +}); + +const makeActionsResult = ({ + actions = [], + done = true, + total = actions.length, + waiting = false, +}: { + readonly actions?: Array; + readonly done?: boolean; + readonly total?: number; + readonly waiting?: boolean; +} = {}): ActivityActionsResult => { + const value: ActivityActionsValue = { + done, + items: [ + { + actions, + hasNextPage: !done, + total, + }, + ], + }; + + return AsyncResult.success(value, { waiting }); +}; + +const filterOptions: ActivityFilterOptionsResult = AsyncResult.success([ + { count: 2, filter: "all" }, + { count: 2, filter: "defi" }, +]); + +const failedActionsResult = (): ActivityActionsResult => + AsyncResult.failure(Cause.fail(undefined as never)); + +const failedFilterOptionsResult = (): ActivityFilterOptionsResult => + AsyncResult.failure( + Cause.fail(undefined as never) + ); + +describe("Activity page projection", () => { + it("makes connectivity authoritative over resource state", () => { + const actionsFailure = failedActionsResult(); + const filtersFailure = failedFilterOptionsResult(); + + expect( + projectActivityPageView({ + actionsResult: actionsFailure, + filterOptionsResult: filtersFailure, + selectedFilter: "all", + walletStatus: "connect-wallet", + }) + ).toEqual({ status: "connect-wallet" }); + expect( + projectActivityPageView({ + actionsResult: actionsFailure, + filterOptionsResult: filtersFailure, + selectedFilter: "all", + walletStatus: "connecting", + }) + ).toEqual({ status: "connecting" }); + }); + + it("distinguishes initial, failed, and globally empty history", () => { + const noFilters: ActivityFilterOptionsResult = AsyncResult.success([]); + + expect( + projectActivityPageView({ + actionsResult: AsyncResult.initial(true), + filterOptionsResult: noFilters, + selectedFilter: "all", + walletStatus: "connected", + }) + ).toEqual({ status: "loading" }); + expect( + projectActivityPageView({ + actionsResult: failedActionsResult(), + filterOptionsResult: noFilters, + selectedFilter: "all", + walletStatus: "connected", + }) + ).toEqual({ status: "failed" }); + expect( + projectActivityPageView({ + actionsResult: makeActionsResult(), + filterOptionsResult: noFilters, + selectedFilter: "all", + walletStatus: "connected", + }) + ).toEqual({ status: "empty" }); + }); + + it("waits for filter options before declaring global activity empty", () => { + expect( + projectActivityPageView({ + actionsResult: makeActionsResult(), + filterOptionsResult: AsyncResult.initial(true), + selectedFilter: "all", + walletStatus: "connected", + }) + ).toEqual({ status: "loading" }); + expect( + projectActivityPageView({ + actionsResult: makeActionsResult(), + filterOptionsResult: AsyncResult.success([], { waiting: true }), + selectedFilter: "all", + walletStatus: "connected", + }) + ).toEqual({ status: "loading" }); + }); + + it("keeps empty activity ready when filter options fail", () => { + expect( + projectActivityPageView({ + actionsResult: makeActionsResult(), + filterOptionsResult: failedFilterOptionsResult(), + selectedFilter: "all", + walletStatus: "connected", + }) + ).toMatchObject({ + actions: [], + filterOptions: [], + status: "ready", + }); + }); + + it("keeps an empty waiting history in loading", () => { + expect( + projectActivityPageView({ + actionsResult: makeActionsResult({ waiting: true }), + filterOptionsResult: AsyncResult.success([]), + selectedFilter: "all", + walletStatus: "connected", + }) + ).toEqual({ status: "loading" }); + }); + + it("keeps an empty filtered result ready for its controls", () => { + expect( + projectActivityPageView({ + actionsResult: makeActionsResult(), + filterOptionsResult: filterOptions, + selectedFilter: "defi", + walletStatus: "connected", + }) + ).toMatchObject({ + actions: [], + filterOptions: filterOptions.value, + selectedFilter: "defi", + status: "ready", + }); + }); + + it("flattens batches and projects counts and pagination", () => { + const first = makeActionItem("first"); + const second = makeActionItem("second"); + const actionsValue: ActivityActionsValue = { + done: false, + items: [ + { + actions: [first], + hasNextPage: true, + total: 3, + }, + { + actions: [second], + hasNextPage: true, + total: 3, + }, + ], + }; + const actionsResult: ActivityActionsResult = + AsyncResult.success(actionsValue); + + expect( + projectActivityPageView({ + actionsResult, + filterOptionsResult: filterOptions, + selectedFilter: "all", + walletStatus: "connected", + }) + ).toMatchObject({ + actions: [first, second], + pagination: { status: "idle" }, + refreshStatus: "fresh", + showingCount: 2, + status: "ready", + total: 3, + }); + }); + + it("keeps activity actions without a readable token", () => { + const visible = makeActionItem("visible"); + const hidden = makeUnavailableActionItem("hidden"); + + expect( + projectActivityPageView({ + actionsResult: makeActionsResult({ + actions: [visible, hidden], + total: 2, + }), + filterOptionsResult: filterOptions, + selectedFilter: "all", + walletStatus: "connected", + }) + ).toMatchObject({ + actions: [visible, hidden], + showingCount: 2, + total: 2, + status: "ready", + }); + }); + + it("projects loading and failed next pages without dropping stale actions", () => { + const action = makeActionItem("retained"); + const previous = makeActionsResult({ + actions: [action], + done: false, + total: 2, + }); + const failedNextPage = AsyncResult.failureWithPrevious( + Cause.fail(undefined as never), + { previous: Option.some(previous) } + ); + + expect( + projectActivityPageView({ + actionsResult: AsyncResult.waiting(previous), + filterOptionsResult: filterOptions, + selectedFilter: "all", + walletStatus: "connected", + }) + ).toMatchObject({ + actions: [action], + pagination: { status: "loading-more" }, + refreshStatus: "refreshing", + status: "ready", + }); + expect( + projectActivityPageView({ + actionsResult: failedNextPage, + filterOptionsResult: filterOptions, + selectedFilter: "all", + walletStatus: "connected", + }) + ).toMatchObject({ + actions: [action], + pagination: { status: "load-more-failed" }, + refreshStatus: "failed", + status: "ready", + }); + }); + + it("preserves failed refresh identity with a completed previous page", () => { + const action = makeActionItem("stale"); + const previous = makeActionsResult({ + actions: [action], + done: true, + total: 1, + }); + const failedRefresh = AsyncResult.failureWithPrevious( + Cause.fail(undefined as never), + { previous: Option.some(previous) } + ); + + expect( + projectActivityPageView({ + actionsResult: failedRefresh, + filterOptionsResult: filterOptions, + selectedFilter: "all", + walletStatus: "connected", + }) + ).toMatchObject({ + actions: [action], + pagination: { status: "complete" }, + refreshStatus: "failed", + status: "ready", + }); + }); + + it("degrades a failed filter-options resource without hiding activity", () => { + const action = makeActionItem("visible"); + + expect( + projectActivityPageView({ + actionsResult: makeActionsResult({ actions: [action] }), + filterOptionsResult: failedFilterOptionsResult(), + selectedFilter: "all", + walletStatus: "connected", + }) + ).toMatchObject({ + actions: [action], + filterOptions: [], + status: "ready", + }); + }); +}); + +describe("Activity page inputs", () => { + it("preserves a selection until settled options prove it unavailable", () => { + expect( + resolveActivityPageFilter({ + filterOptionsResult: AsyncResult.initial(true), + selectedFilter: "defi", + }) + ).toBe("defi"); + expect( + resolveActivityPageFilter({ + filterOptionsResult: failedFilterOptionsResult(), + selectedFilter: "defi", + }) + ).toBe("defi"); + expect( + resolveActivityPageFilter({ + filterOptionsResult: AsyncResult.success([{ count: 1, filter: "all" }]), + selectedFilter: "defi", + }) + ).toBe("all"); + expect( + resolveActivityPageFilter({ + filterOptionsResult: filterOptions, + selectedFilter: "defi", + }) + ).toBe("defi"); + }); + + it("distinguishes wallet bootstrap from a settled disconnect", () => { + expect( + resolveActivityPageWalletStatus({ + configResult: AsyncResult.initial(true), + connectionStatus: "disconnected", + }) + ).toBe("connecting"); + expect( + resolveActivityPageWalletStatus({ + configResult: AsyncResult.success({} as never), + connectionStatus: "connecting", + }) + ).toBe("connecting"); + expect( + resolveActivityPageWalletStatus({ + configResult: AsyncResult.success({} as never), + connectionStatus: "disconnected", + }) + ).toBe("connect-wallet"); + expect( + resolveActivityPageWalletStatus({ + configResult: AsyncResult.success({} as never), + connectionStatus: "connected", + }) + ).toBe("connected"); + }); +}); + +describe("Activity dashboard split", () => { + it("only splits when the feed has actions to inspect", () => { + expect(shouldShowActivityDashboardSplit({ status: "empty" })).toBe(false); + expect(shouldShowActivityDashboardSplit({ status: "loading" })).toBe(false); + expect( + shouldShowActivityDashboardSplit({ + actions: [], + filterOptions: [{ count: 0, filter: "all" }], + pagination: { status: "complete" }, + refreshStatus: "fresh", + selectedFilter: "all", + showingCount: 0, + status: "ready", + total: 0, + }) + ).toBe(false); + expect( + shouldShowActivityDashboardSplit({ + actions: [makeActionItem("a1")], + filterOptions: [{ count: 1, filter: "all" }], + pagination: { status: "complete" }, + refreshStatus: "fresh", + selectedFilter: "all", + showingCount: 1, + status: "ready", + total: 1, + }) + ).toBe(true); + }); +}); diff --git a/packages/widget/tests/features/activity-routes.test.tsx b/packages/widget/tests/features/activity-routes.test.tsx new file mode 100644 index 000000000..5111fe714 --- /dev/null +++ b/packages/widget/tests/features/activity-routes.test.tsx @@ -0,0 +1,137 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { createRoutesFromElements, type RouteObject } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import { createActivityActionRoutes } from "../../src/features/activity/composition"; + +vi.mock("../../src/features/activity/react/activity-action-route", () => ({ + ActivityActionRoute: function ActivityActionRoute() { + return null; + }, +})); +vi.mock( + "../../src/features/activity/ui/activity-details/activity-details.page", + () => ({ + ActivityDetailsPage: function ActivityDetailsPage() { + return null; + }, + }) +); +vi.mock("../../src/features/classic-transaction-flow/views", () => ({ + YieldActionContinuationCompletePage: + function YieldActionContinuationCompletePage() { + return null; + }, + YieldActionContinuationExecutionScope: + function YieldActionContinuationExecutionScope() { + return null; + }, + YieldActionContinuationStepsPage: + function YieldActionContinuationStepsPage() { + return null; + }, +})); + +type ElementProps = Readonly<{ + children?: ReactNode; + fallbackPath?: string; + presentation?: string; +}>; + +const nameOf = (element: ReactElement | null): string | null => { + if (!element) return null; + const type = element.type; + const name = typeof type === "function" ? type.name : String(type); + const props = element.props as ElementProps; + if (props.presentation) return `${name}(${props.presentation})`; + if (props.fallbackPath) return `${name}(${props.fallbackPath})`; + return name; +}; + +type RouteDescription = Readonly<{ + children: ReadonlyArray; + element: string | null; + index: boolean; + path: string | null; +}>; + +const describeRoute = (route: RouteObject): RouteDescription => ({ + children: (route.children ?? []).map(describeRoute), + element: isValidElement(route.element) ? nameOf(route.element) : null, + index: route.index ?? false, + path: route.path ?? null, +}); + +const actionIdRoute = (presentation: string): RouteDescription => ({ + children: [ + { + children: [], + element: "ActivityDetailsPage", + index: true, + path: null, + }, + { + children: [ + { + children: [], + element: "YieldActionContinuationStepsPage", + index: false, + path: "steps", + }, + { + children: [], + element: "YieldActionContinuationCompletePage", + index: false, + path: "complete", + }, + ], + element: "YieldActionContinuationExecutionScope", + index: false, + path: null, + }, + ], + element: `ActivityActionRoute(${presentation})`, + index: false, + path: ":actionId", +}); + +const ActionScopeGuard = function ActionScopeGuard() { + return null; +}; + +describe("Activity routes", () => { + it("uses Classic details only under an action id", () => { + const routes = createRoutesFromElements( + createActivityActionRoutes("Classic") + ); + + expect(routes.map(describeRoute)).toEqual([actionIdRoute("Classic")]); + }); + + it("keeps Dashboard default selection outside the action-scope guard", () => { + const routes = createRoutesFromElements( + createActivityActionRoutes("Dashboard", { ActionScopeGuard }) + ); + + expect(routes.map(describeRoute)).toEqual([ + { + children: [ + { + children: [], + element: "ActivityDetailsPage", + index: true, + path: null, + }, + ], + element: "ActivityActionRoute(Dashboard)", + index: false, + path: null, + }, + { + children: [actionIdRoute("Dashboard")], + element: "ActionScopeGuard(/activity)", + index: false, + path: null, + }, + ]); + }); +}); diff --git a/packages/widget/tests/features/activity-selection.test.ts b/packages/widget/tests/features/activity-selection.test.ts new file mode 100644 index 000000000..bd63a98a7 --- /dev/null +++ b/packages/widget/tests/features/activity-selection.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { + ActivityDefaultIntent, + ActivityExplicitIntent, + findActivityActionInFeed, + parseActivityRouteIntent, + resolveActivityHighlightActionId, + resolveActivitySelection, + resolveDefaultSelectedActionId, + resolveUnavailableActivitySelection, +} from "../../src/features/activity/state/details"; +import type { ActivityPageView } from "../../src/features/activity/state/page"; +import { yieldApiActionFixture } from "../fixtures"; + +const feedItem = (id: string) => ({ + actionData: yieldApiActionFixture({ id }), + validatorsData: [], + walletScope: {} as never, + yieldData: null, +}); + +const readyView = ( + refreshStatus: "failed" | "fresh" | "refreshing", + actions = [feedItem("newest-action")] +): ActivityPageView => ({ + actions, + filterOptions: [], + pagination: { status: "complete" }, + refreshStatus, + selectedFilter: "all", + showingCount: actions.length, + status: "ready", + total: actions.length, +}); + +describe("Activity default selection", () => { + it("waits while the feed is loading or refreshing", () => { + expect(resolveDefaultSelectedActionId({ status: "loading" })).toBe( + "loading" + ); + expect(resolveDefaultSelectedActionId(readyView("refreshing"))).toBe( + "loading" + ); + expect(resolveDefaultSelectedActionId(readyView("failed"))).toBe("loading"); + }); + + it("selects the first fresh feed action", () => { + expect(resolveDefaultSelectedActionId(readyView("fresh"))).toBe( + "newest-action" + ); + }); + + it("is unavailable when the feed is empty", () => { + expect(resolveDefaultSelectedActionId({ status: "empty" })).toBe(null); + }); +}); + +describe("Activity selection from intent", () => { + it("keeps an explicit action id without waiting on the feed", () => { + expect( + resolveActivitySelection( + new ActivityExplicitIntent({ actionId: "picked-action" as never }), + { status: "loading" } + ) + ).toBe("picked-action"); + }); + + it("resolves default intent from the fresh feed", () => { + expect( + resolveActivitySelection(new ActivityDefaultIntent(), readyView("fresh")) + ).toBe("newest-action"); + }); + + it("waits on default intent while the feed is refreshing", () => { + expect( + resolveActivitySelection( + new ActivityDefaultIntent(), + readyView("refreshing") + ) + ).toBe("loading"); + }); + + it("hides the feed highlight while default selection is still loading", () => { + expect( + resolveActivityHighlightActionId( + new ActivityDefaultIntent(), + readyView("refreshing") + ) + ).toBe(null); + expect( + resolveActivityHighlightActionId( + new ActivityDefaultIntent(), + readyView("fresh") + ) + ).toBe("newest-action"); + }); +}); + +describe("Activity feed lookup for details", () => { + it("returns the enriched feed row when the id is present", () => { + const item = feedItem("picked-action"); + expect( + findActivityActionInFeed( + "picked-action" as never, + readyView("fresh", [feedItem("other"), item]) + ) + ).toBe(item); + }); + + it("returns null when the feed is not ready or the id is missing", () => { + expect( + findActivityActionInFeed("picked-action" as never, { status: "loading" }) + ).toBe(null); + expect( + findActivityActionInFeed("missing-action" as never, readyView("fresh")) + ).toBe(null); + }); + + it("still finds a row while the feed is refreshing", () => { + const item = feedItem("picked-action"); + expect( + findActivityActionInFeed( + "picked-action" as never, + readyView("refreshing", [item]) + ) + ).toBe(item); + }); +}); + +describe("Activity route intent parsing", () => { + it("treats a missing param as default only when allowed", () => { + expect( + parseActivityRouteIntent({ + actionIdParam: undefined, + allowDefault: true, + }) + ).toEqual({ intent: new ActivityDefaultIntent(), status: "ok" }); + expect( + parseActivityRouteIntent({ + actionIdParam: undefined, + allowDefault: false, + }) + ).toEqual({ status: "missing" }); + }); + + it("decodes a valid action id as explicit intent", () => { + expect( + parseActivityRouteIntent({ + actionIdParam: "action-1", + allowDefault: false, + }) + ).toEqual({ + intent: new ActivityExplicitIntent({ actionId: "action-1" as never }), + status: "ok", + }); + }); + + it("rejects an empty action id segment", () => { + expect( + parseActivityRouteIntent({ + actionIdParam: "", + allowDefault: true, + }) + ).toEqual({ intent: new ActivityDefaultIntent(), status: "ok" }); + }); +}); + +describe("Unavailable activity selection", () => { + it("clears an explicit action route that is stale for the current wallet", () => { + expect( + resolveUnavailableActivitySelection( + new ActivityExplicitIntent({ actionId: "stale-action" as never }) + ) + ).toBe("clear-route"); + }); + + it("leaves the details pane empty when default selection has nothing", () => { + expect( + resolveUnavailableActivitySelection(new ActivityDefaultIntent()) + ).toBe("empty"); + }); +}); diff --git a/packages/widget/tests/features/activity-workflow.dom.test.tsx b/packages/widget/tests/features/activity-workflow.dom.test.tsx new file mode 100644 index 000000000..2353e9add --- /dev/null +++ b/packages/widget/tests/features/activity-workflow.dom.test.tsx @@ -0,0 +1,182 @@ +import { Schema } from "effect"; +import { act, type ReactNode } from "react"; +import { I18nextProvider } from "react-i18next"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import { SKAtomRegistryProvider } from "../../src/app/composition/providers/atom-runtime"; +import { applicationRoutes } from "../../src/app/routes/application-routes"; +import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; +import type { ActivityActionItem } from "../../src/features/activity/model/activity-action"; +import type { ActivityPageView } from "../../src/features/activity/state/page"; +import { ActivityPagePresentation } from "../../src/features/activity/ui/activity-page/activity-page-presentation"; +import { CompletePageComponent } from "../../src/features/classic-transaction-flow/ui/complete/pages/common.page.tsx"; +import { createWidgetI18nInstance } from "../../src/services/translation/widget-translation"; +import { yieldApiActionFixture, yieldApiYieldFixture } from "../fixtures"; +import { render } from "../utils/test-utils.dom.tsx"; + +const i18nInstance = createWidgetI18nInstance(); + +vi.mock( + "../../src/features/activity/ui/activity-page/components/action-list-item", + () => ({ + ActionListItem: ({ action }: { readonly action: ActivityActionItem }) => ( +
{action.actionData.id}
+ ), + }) +); + +const settings = { + apiKey: "test-api-key", + variant: "default" as const, +}; + +const wrap = (children: ReactNode) => ( + + + {children} + + +); + +describe("activity and completion workflows", () => { + it("renders every headless Activity page status and its retry controls", async () => { + const onActionSelect = vi.fn(); + const onFilterSelect = vi.fn(); + const onLoadMore = vi.fn(); + const onRetry = vi.fn(); + const activityPage = (view: ActivityPageView) => ( + + ); + const app = await render(wrap(activityPage({ status: "connect-wallet" }))); + + expect( + app.container.querySelector('[data-rk="activity-connect-wallet"]') + ?.textContent + ).toContain("Connect"); + expect( + app.container.querySelector('[data-rk="activity-connect-wallet"] button') + ).toBeNull(); + + await app.rerender(wrap(activityPage({ status: "connecting" }))); + expect( + app.container.querySelectorAll('[data-rk="activity-page-skeleton"]') + ).toHaveLength(1); + + await app.rerender(wrap(activityPage({ status: "loading" }))); + expect( + app.container.querySelectorAll('[data-rk="activity-page-skeleton"]') + ).toHaveLength(1); + + await app.rerender(wrap(activityPage({ status: "failed" }))); + const pageRetry = app.container.querySelector( + '[data-rk="activity-page-error"] button' + ); + await act(async () => pageRetry?.click()); + expect(onRetry).toHaveBeenCalledOnce(); + + await app.rerender(wrap(activityPage({ status: "empty" }))); + expect( + app.container.querySelector('[data-rk="activity-empty"]')?.textContent + ).toContain("No previous activity"); + + await app.rerender( + wrap( + activityPage({ + actions: [], + filterOptions: [ + { count: 2, filter: "all" }, + { count: 0, filter: "defi" }, + ], + pagination: { status: "complete" }, + refreshStatus: "fresh", + selectedFilter: "defi", + showingCount: 0, + status: "ready", + total: 0, + }) + ) + ); + expect(app.container.textContent).toContain("DeFi"); + expect(app.container.textContent).toContain("No previous activity"); + + const yieldModel = yieldApiYieldFixture(); + const item: ActivityActionItem = { + actionData: yieldApiActionFixture({ + id: "retained-action", + yieldId: yieldModel.id, + }), + validatorsData: [], + walletScope: new WalletScopeKey({ + address: Schema.decodeSync( + Schema.NonEmptyString.pipe(Schema.brand("WalletAddress")) + )("0x0000000000000000000000000000000000000001"), + network: "ethereum", + }), + yieldData: yieldModel, + }; + await app.rerender( + wrap( + activityPage({ + actions: [item], + filterOptions: [], + pagination: { status: "load-more-failed" }, + refreshStatus: "failed", + selectedFilter: "all", + showingCount: 1, + status: "ready", + total: 2, + }) + ) + ); + expect(app.container.textContent).toContain("Showing 1 of 2"); + const loadMoreRetry = app.container.querySelector( + '[data-rk="activity-load-more-error"] button' + ); + await act(async () => loadMoreRetry?.click()); + expect(onLoadMore).toHaveBeenCalledOnce(); + }); + + it("renders completion transaction actions from explicit workflow input", async () => { + const onViewTransactionClick = vi.fn(); + const app = await render( + wrap( + + ) + ); + const transactionButton = app.container.querySelector( + 'button:not([data-rk^="footer-button"])' + ); + + expect(transactionButton?.textContent).toContain("View Stake transaction"); + + await act(async () => transactionButton?.click()); + + expect(onViewTransactionClick).toHaveBeenCalledWith( + "https://explorer.test/tx" + ); + }); +}); diff --git a/packages/widget/tests/features/borrow-flow-atoms.test.ts b/packages/widget/tests/features/borrow-flow-atoms.test.ts new file mode 100644 index 000000000..bdec04f70 --- /dev/null +++ b/packages/widget/tests/features/borrow-flow-atoms.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { Effect, Layer, Schema, Stream, SubscriptionRef } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; +import { walletRuntime } from "../../src/app/runtime/wallet-runtime"; +import { Action } from "../../src/domain/borrow/execution/action"; +import { Transaction } from "../../src/domain/borrow/execution/transaction"; +import { IntegrationId, MarketId } from "../../src/domain/borrow/ids"; +import { WalletAddress } from "../../src/domain/identity/identifiers"; +import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; +import type { + BorrowFlowSession, + BorrowTransactionFlowIntake, +} from "../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; +import { + currentBorrowFlowSessionAtom, + startBorrowTransactionFlowAtom, +} from "../../src/features/borrow-transaction-flow/state/atoms/borrow-flow"; +import { + currentBorrowFlowSessionRootAtom, + makeBorrowFlowExecutionScope, + makeBorrowFlowReviewScope, +} from "../../src/features/borrow-transaction-flow/state/atoms/borrow-flow-session"; +import type { BorrowFlowSessionHandle } from "../../src/features/borrow-transaction-flow/state/orchestration/borrow-flow-session"; +import { BorrowTransactionFlowService } from "../../src/features/borrow-transaction-flow/state/orchestration/borrow-transaction-flow-service"; +import { initializeTransactionWorkflow } from "../../src/services/transaction-workflow/internal/model"; +import { BorrowTransactionWorkflowInput } from "../../src/services/transaction-workflow/transaction-workflow-model"; + +const address = Schema.decodeSync(WalletAddress)( + "0x0000000000000000000000000000000000000001" +); +const walletScope = new WalletScopeKey({ address, network: "base" }); +const intake: BorrowTransactionFlowIntake = { + command: { + action: "borrow", + address, + args: { marketId: Schema.decodeSync(MarketId)("market-1") }, + integrationId: Schema.decodeSync(IntegrationId)("provider-1"), + }, + entry: { _tag: "BorrowEntry" }, + summary: { + action: "borrow", + borrowAmount: "1", + existingCollateralUsd: "100", + existingDebtUsd: "0", + loanTokenSymbol: "USDC", + marketLabel: "USDC market", + network: "base", + projectedCollateralUsd: "100", + projectedDebtUsd: "1", + providerName: "Provider", + riskStatus: "unavailable", + warnings: [], + }, +}; + +const makeSession = (epoch: number): BorrowFlowSession => ({ + epoch, + intake, + walletScope, +}); + +const transaction = Schema.decodeSync(Transaction)({ + address, + chainId: "8453", + id: "transaction-1", + network: "base", + signablePayload: "0x00", + signingFormat: "EVM_TRANSACTION", + status: "WAITING_FOR_SIGNATURE", + type: "BORROW", +}); + +const action = Schema.decodeSync(Action)({ + action: "borrow", + address, + createdAt: "2026-01-01T00:00:00.000Z", + currentStep: 1, + hasNextStep: false, + id: "action-1", + integrationId: "provider-1", + rawArguments: intake.command.args, + status: "CREATED", + totalSteps: 1, + transactions: [Schema.encodeSync(Transaction)(transaction)], +}); + +const makeSessionHandle = (): BorrowFlowSessionHandle => ({ + acquireExecution: () => + Effect.succeed({ _tag: "RejectedNoReservation" } as const), + acquireReview: () => + Effect.succeed({ + back: () => Effect.succeed({ _tag: "Accepted" } as const), + confirm: () => Effect.succeed({ _tag: "Confirmed" } as const), + }), + intake, +}); + +describe("Borrow Flow Atom bridge", () => { + it.effect( + "projects service state and binds Session acquisition to the root Atom lifetime", + () => + Effect.gen(function* () { + const current = yield* SubscriptionRef.make( + null + ); + const probes = { acquired: 0, released: 0 }; + const startInputs: Array = []; + const service = BorrowTransactionFlowService.of({ + acquireSession: () => + Effect.acquireRelease( + Effect.sync(() => { + probes.acquired += 1; + return { + _tag: "Acquired", + session: makeSessionHandle(), + } as const; + }), + () => + Effect.sync(() => { + probes.released += 1; + }).pipe(Effect.andThen(SubscriptionRef.set(current, null))) + ), + currentSession: SubscriptionRef.changes(current), + start: (input) => + Effect.gen(function* () { + startInputs.push(input); + const session = makeSession(1); + yield* SubscriptionRef.set(current, session); + return { _tag: "Started", session } as const; + }), + }); + const registry = AtomRegistry.make({ + initialValues: [ + Atom.initialValue( + walletRuntime.layer, + Layer.succeed(BorrowTransactionFlowService, service) as never + ), + ], + }); + + registry.set(startBorrowTransactionFlowAtom, intake); + yield* Effect.promise(() => + vi.waitFor(() => + expect(registry.get(currentBorrowFlowSessionAtom)?.epoch).toBe(1) + ) + ); + expect(startInputs).toEqual([intake]); + + const rootAtom = registry.get(currentBorrowFlowSessionRootAtom); + if (!rootAtom) + throw new Error("Expected a Borrow Flow Session root Atom"); + const releaseRoot = registry.mount(rootAtom); + yield* Effect.promise(() => + vi.waitFor(() => expect(probes.acquired).toBe(1)) + ); + + releaseRoot(); + yield* Effect.promise(() => + vi.waitFor(() => expect(probes.released).toBe(1)) + ); + registry.dispose(); + }) + ); + + it.effect( + "binds Review and Execution handles to route scope and forwards their commands", + () => + Effect.gen(function* () { + const session = makeSession(1); + const current = yield* SubscriptionRef.make( + session + ); + const reviewProbes = { acquired: 0, released: 0 }; + const executionProbes = { acquired: 0, released: 0 }; + const reviewBack = vi.fn(() => + Effect.succeed({ _tag: "Accepted" } as const) + ); + const reviewConfirm = vi.fn(() => + Effect.succeed({ _tag: "Confirmed" } as const) + ); + const executionBack = vi.fn(() => + Effect.succeed({ _tag: "Accepted" } as const) + ); + const executionFinish = vi.fn(() => + Effect.succeed({ _tag: "Accepted" } as const) + ); + const workflowDispatch = vi.fn(() => + Effect.succeed({ _tag: "Accepted" } as const) + ); + const workflowState = initializeTransactionWorkflow( + new BorrowTransactionWorkflowInput({ action, walletScope }) + ); + const sessionHandle: BorrowFlowSessionHandle = { + acquireExecution: () => + Effect.acquireRelease( + Effect.sync(() => { + executionProbes.acquired += 1; + return { + _tag: "Acquired", + execution: { + back: executionBack, + finish: executionFinish, + runWorkflow: workflowDispatch, + states: Stream.succeed(workflowState), + }, + } as const; + }), + () => + Effect.sync(() => { + executionProbes.released += 1; + }) + ), + acquireReview: () => + Effect.acquireRelease( + Effect.sync(() => { + reviewProbes.acquired += 1; + return { back: reviewBack, confirm: reviewConfirm }; + }), + () => + Effect.sync(() => { + reviewProbes.released += 1; + }) + ), + intake, + }; + const service = BorrowTransactionFlowService.of({ + acquireSession: () => + Effect.acquireRelease( + Effect.succeed({ + _tag: "Acquired", + session: sessionHandle, + } as const), + () => Effect.void + ), + currentSession: SubscriptionRef.changes(current), + start: () => Effect.succeed({ _tag: "Started", session } as const), + }); + const registry = AtomRegistry.make({ + initialValues: [ + Atom.initialValue( + walletRuntime.layer, + Layer.succeed(BorrowTransactionFlowService, service) as never + ), + ], + }); + + const sessionRootAtom = registry.get(currentBorrowFlowSessionRootAtom); + if (!sessionRootAtom) throw new Error("Expected a Session root Atom"); + const releaseSession = registry.mount(sessionRootAtom); + const sessionModule = registry.get(sessionRootAtom); + + const reviewRootAtom = makeBorrowFlowReviewScope(sessionModule); + const reviewModule = registry.get(reviewRootAtom); + const releaseReview = registry.mount(reviewRootAtom); + registry.set(reviewModule.facade.confirmAtom, undefined); + registry.set(reviewModule.facade.backAtom, undefined); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(reviewProbes.acquired).toBe(1); + expect(reviewConfirm).toHaveBeenCalledOnce(); + expect(reviewBack).toHaveBeenCalledOnce(); + }) + ); + releaseReview(); + yield* Effect.promise(() => + vi.waitFor(() => expect(reviewProbes.released).toBe(1)) + ); + + const executionRootAtom = makeBorrowFlowExecutionScope(sessionModule); + const executionModule = registry.get(executionRootAtom); + const releaseExecution = registry.mount(executionRootAtom); + registry.set(executionModule.facade.workflowCommandAtom, { + _tag: "Retry", + }); + registry.set(executionModule.facade.backAtom, undefined); + registry.set(executionModule.facade.finishAtom, undefined); + + yield* Effect.promise(() => + vi.waitFor(() => { + const view = registry.get(executionModule.facade.viewAtom); + expect(executionProbes.acquired).toBe(1); + expect(view.action?.id).toBe(action.id); + expect(view.currentTransaction?.id).toBe(transaction.id); + expect(workflowDispatch).toHaveBeenCalledWith({ _tag: "Retry" }); + expect(executionBack).toHaveBeenCalledOnce(); + expect(executionFinish).toHaveBeenCalledOnce(); + }) + ); + releaseExecution(); + yield* Effect.promise(() => + vi.waitFor(() => expect(executionProbes.released).toBe(1)) + ); + + releaseSession(); + registry.dispose(); + }) + ); +}); diff --git a/packages/widget/tests/features/borrow-flow-route-identity.dom.test.tsx b/packages/widget/tests/features/borrow-flow-route-identity.dom.test.tsx new file mode 100644 index 000000000..5f69499dd --- /dev/null +++ b/packages/widget/tests/features/borrow-flow-route-identity.dom.test.tsx @@ -0,0 +1,49 @@ +import { RegistryProvider } from "@effect/atom-react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router"; +import { describe, expect, it } from "vitest"; +import type { BorrowFlowSession } from "../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; +import { BorrowTransactionFlowRoute } from "../../src/features/borrow-transaction-flow/react/borrow-flow-route"; +import { currentBorrowFlowSessionAtom } from "../../src/features/borrow-transaction-flow/state/atoms/borrow-flow"; +import { currentBorrowFlowSessionRootAtom } from "../../src/features/borrow-transaction-flow/state/atoms/borrow-flow-session"; +import { render } from "../utils/test-utils.dom.tsx"; + +const LocationProbe = () => { + const location = useLocation(); + return {location.pathname}; +}; + +describe("Borrow Flow route identity", () => { + it("returns a mismatched Market Position Session to the routed market", async () => { + const session = { + epoch: 1, + intake: { + entry: { _tag: "MarketPosition", marketId: "market-a" }, + }, + } as BorrowFlowSession; + const app = await render( + + + + } + path="/positions/borrow/:marketId/review" + /> + } + path="/positions/borrow/:marketId" + /> + + + + ); + + expect( + app.container.querySelector('[data-testid="location"]')?.textContent + ).toBe("/positions/borrow/market-b"); + }); +}); diff --git a/packages/widget/tests/features/borrow-flow-routes.test.tsx b/packages/widget/tests/features/borrow-flow-routes.test.tsx new file mode 100644 index 000000000..1e89de026 --- /dev/null +++ b/packages/widget/tests/features/borrow-flow-routes.test.tsx @@ -0,0 +1,123 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { createRoutesFromElements, type RouteObject } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import { createBorrowTransactionFlowRoutes } from "../../src/features/borrow-transaction-flow/composition"; + +vi.mock( + "../../src/features/borrow-transaction-flow/react/borrow-flow-route", + () => ({ + BorrowTransactionFlowCompletionGuard: + function BorrowTransactionFlowCompletionGuard() { + return null; + }, + BorrowTransactionFlowExecutionScope: + function BorrowTransactionFlowExecutionScope() { + return null; + }, + BorrowTransactionFlowReviewRoute: + function BorrowTransactionFlowReviewRoute() { + return null; + }, + BorrowTransactionFlowRoute: function BorrowTransactionFlowRoute() { + return null; + }, + }) +); +vi.mock("../../src/features/borrow-transaction-flow/ui/complete", () => ({ + BorrowCompletePage: function BorrowCompletePage() { + return null; + }, +})); +vi.mock("../../src/features/borrow-transaction-flow/ui/review", () => ({ + BorrowReviewPage: function BorrowReviewPage() { + return null; + }, +})); +vi.mock("../../src/features/borrow-transaction-flow/ui/steps", () => ({ + BorrowStepsPage: function BorrowStepsPage() { + return null; + }, +})); + +type ElementProps = Readonly<{ + children?: ReactNode; + expected?: string; +}>; + +type RouteContract = Readonly<{ + children: ReadonlyArray; + element: string | null; + path: string | null; +}>; + +const getElementName = (element: ReactElement): string => { + if (typeof element.type === "string") return element.type; + if (typeof element.type === "function") return element.type.name; + return "Anonymous"; +}; + +const describeElement = (node: ReactNode): string | null => { + if (!isValidElement(node)) return null; + + const name = getElementName(node); + return node.props.expected ? `${name}(${node.props.expected})` : name; +}; + +const describeRoute = (route: RouteObject): RouteContract => ({ + children: (route.children ?? []).map(describeRoute), + element: describeElement(route.element), + path: route.path ?? null, +}); + +describe("Borrow Transaction Flow routes", () => { + it.each(["BorrowEntry", "MarketPosition"] as const)( + "publishes the %s journey", + (entry) => { + const routes = createRoutesFromElements( + createBorrowTransactionFlowRoutes({ entry }) + ); + + expect(routes.map(describeRoute)).toEqual([ + { + children: [ + { + children: [ + { + children: [], + element: "BorrowReviewPage", + path: "review", + }, + ], + element: "BorrowTransactionFlowReviewRoute", + path: null, + }, + { + children: [ + { + children: [], + element: "BorrowStepsPage", + path: "steps", + }, + { + children: [ + { + children: [], + element: "BorrowCompletePage", + path: "complete", + }, + ], + element: "BorrowTransactionFlowCompletionGuard", + path: null, + }, + ], + element: "BorrowTransactionFlowExecutionScope", + path: null, + }, + ], + element: `BorrowTransactionFlowRoute(${entry})`, + path: null, + }, + ]); + } + ); +}); diff --git a/packages/widget/tests/features/borrow-review-confirmation-error.test.ts b/packages/widget/tests/features/borrow-review-confirmation-error.test.ts new file mode 100644 index 000000000..295279bc7 --- /dev/null +++ b/packages/widget/tests/features/borrow-review-confirmation-error.test.ts @@ -0,0 +1,39 @@ +import { Cause } from "effect"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { describe, expect, it } from "vitest"; +import { + BorrowReviewConfirmationError, + normalizeBorrowReviewConfirmationResult, +} from "../../src/features/borrow-transaction-flow/state/borrow-review-confirmation-error"; +import { presentBorrowReviewConfirmationError } from "../../src/features/borrow-transaction-flow/ui/borrow-review-confirmation-error"; + +describe("Borrow Review confirmation error presentation", () => { + it("maps the feature-owned error to safe localized copy", () => { + const error = new BorrowReviewConfirmationError({ + cause: "internal detail", + }); + + expect(presentBorrowReviewConfirmationError(error)).toEqual({ + descriptionKey: + "dashboard.borrow.review_page.confirmation_error_description", + titleKey: "dashboard.borrow.execution_page.error_title", + }); + }); + + it("normalizes typed confirmation failures behind the feature boundary", () => { + const result = normalizeBorrowReviewConfirmationResult( + AsyncResult.failure(Cause.fail("internal failure")) + ); + + expect(AsyncResult.isFailure(result)).toBe(true); + if (AsyncResult.isFailure(result)) { + expect(Cause.findError(result.cause)).toEqual( + expect.objectContaining({ + success: expect.objectContaining({ + _tag: "BorrowReviewConfirmationError", + }), + }) + ); + } + }); +}); diff --git a/packages/widget/tests/features/borrow-routes.test.tsx b/packages/widget/tests/features/borrow-routes.test.tsx new file mode 100644 index 000000000..42de9c611 --- /dev/null +++ b/packages/widget/tests/features/borrow-routes.test.tsx @@ -0,0 +1,191 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { createRoutesFromElements, type RouteObject } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import { + createBorrowEntryRoutes, + createBorrowMarketPositionRoutes, +} from "../../src/features/borrow/composition"; +import { createBorrowTransactionFlowRoutes } from "../../src/features/borrow-transaction-flow/composition"; +import { WalletScopeRouteGuard } from "../../src/features/wallet/composition"; + +const routeComposition = { + WalletScopeRouteGuard, + createBorrowTransactionFlowRoutes, +}; + +vi.mock("../../src/features/borrow/borrow-entry/ui/layout", () => ({ + BorrowLayout: function BorrowLayout() { + return null; + }, +})); +vi.mock("../../src/features/borrow/borrow-entry/ui/page", () => ({ + BorrowFormPage: function BorrowFormPage() { + return null; + }, +})); +vi.mock("../../src/features/borrow/market-position/ui/action.page", () => ({ + BorrowPositionActionPage: function BorrowPositionActionPage() { + return null; + }, +})); +vi.mock("../../src/features/borrow/market-position/ui/actions.page", () => ({ + BorrowPositionActionsPage: function BorrowPositionActionsPage() { + return null; + }, +})); +vi.mock("../../src/features/borrow/market-position/ui/details.page", () => ({ + BorrowPositionDetailsPage: function BorrowPositionDetailsPage() { + return null; + }, +})); +vi.mock("../../src/features/borrow/wallet/ui/connected-wallet-route", () => ({ + BorrowConnectedWalletRoute: function BorrowConnectedWalletRoute() { + return null; + }, +})); +vi.mock("../../src/features/wallet/composition", () => ({ + WalletScopeRouteGuard: function WalletScopeRouteGuard() { + return null; + }, +})); +vi.mock("../../src/features/borrow-transaction-flow/composition", async () => { + const { Route } = await import("react-router"); + return { + createBorrowTransactionFlowRoutes: ({ + entry, + }: { + readonly entry: string; + }) => , + }; +}); + +type ElementProps = Readonly<{ children?: ReactNode }>; + +type RouteContract = Readonly<{ + children: ReadonlyArray; + element: string | null; + index: boolean; + path: string | null; +}>; + +const getElementName = (element: ReactElement): string => { + if (typeof element.type === "string") return element.type; + if (typeof element.type === "function") return element.type.name; + return "Anonymous"; +}; + +const describeElement = (node: ReactNode): string | null => { + if (!isValidElement(node)) return null; + return getElementName(node); +}; + +const describeRoute = (route: RouteObject): RouteContract => ({ + children: (route.children ?? []).map(describeRoute), + element: describeElement(route.element), + index: route.index === true, + path: route.path ?? null, +}); + +describe("Borrow routes", () => { + it("publishes the Borrow Entry topology", () => { + const routes = createRoutesFromElements( + createBorrowEntryRoutes(routeComposition) + ); + + expect(routes.map(describeRoute)).toEqual([ + { + children: [ + { + children: [], + element: "BorrowFormPage", + index: true, + path: null, + }, + { + children: [ + { + children: [ + { + children: [], + element: null, + index: false, + path: "BorrowEntry-flow", + }, + ], + element: "BorrowConnectedWalletRoute", + index: false, + path: null, + }, + ], + element: "WalletScopeRouteGuard", + index: false, + path: null, + }, + ], + element: "BorrowLayout", + index: false, + path: "borrow", + }, + { + children: [], + element: "Navigate", + index: false, + path: "borrow/*", + }, + ]); + }); + + it("publishes the Market Position topology", () => { + const routes = createRoutesFromElements( + createBorrowMarketPositionRoutes(routeComposition) + ); + + expect(routes.map(describeRoute)).toEqual([ + { + children: [ + { + children: [ + { + children: [ + { + children: [], + element: "BorrowPositionActionsPage", + index: true, + path: null, + }, + { + children: [], + element: "BorrowPositionActionPage", + index: false, + path: "action/:actionId", + }, + { + children: [], + element: null, + index: false, + path: "MarketPosition-flow", + }, + ], + element: "BorrowPositionDetailsPage", + index: false, + path: "borrow/:marketId", + }, + ], + element: "BorrowConnectedWalletRoute", + index: false, + path: null, + }, + ], + element: "WalletScopeRouteGuard", + index: false, + path: null, + }, + { + children: [], + element: "Navigate", + index: false, + path: "borrow/*", + }, + ]); + }); +}); diff --git a/packages/widget/tests/features/borrow-transaction-flow-service.test.ts b/packages/widget/tests/features/borrow-transaction-flow-service.test.ts new file mode 100644 index 000000000..06a9b1414 --- /dev/null +++ b/packages/widget/tests/features/borrow-transaction-flow-service.test.ts @@ -0,0 +1,632 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + Deferred, + Effect, + Exit, + Fiber, + Layer, + Option, + Schema, + Scope, + Stream, + SubscriptionRef, +} from "effect"; +import { TestClock } from "effect/testing"; +import { Action } from "../../src/domain/borrow/execution/action"; +import { Transaction } from "../../src/domain/borrow/execution/transaction"; +import { IntegrationId, MarketId } from "../../src/domain/borrow/ids"; +import { WalletAddress } from "../../src/domain/identity/identifiers"; +import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; +import type { BorrowTransactionFlowIntake } from "../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; +import { BorrowActionCreationError } from "../../src/features/borrow-transaction-flow/state/orchestration/borrow-flow-review"; +import { BorrowTransactionFlowService } from "../../src/features/borrow-transaction-flow/state/orchestration/borrow-transaction-flow-service"; +import { BorrowOperations } from "../../src/services/api/operations"; +import { WidgetConfigService } from "../../src/services/config/widget-config"; +import { + toWidgetPath, + type WidgetNavigation, + type WidgetNavigationCommand, + WidgetNavigationError, +} from "../../src/services/navigation/widget-navigation"; +import { initializeTransactionWorkflow } from "../../src/services/transaction-workflow/internal/model"; +import { + BorrowTransactionWorkflowInput, + type TransactionWorkflowInput, + type TransactionWorkflowState, +} from "../../src/services/transaction-workflow/transaction-workflow-model"; +import { TransactionWorkflowService } from "../../src/services/transaction-workflow/transaction-workflow-service"; +import type { WalletState } from "../../src/services/wallet/wallet-state"; +import { + makeConnectedWalletState, + makeConnectingWalletState, +} from "../fixtures/wallet-state"; +import { makeTestTracking } from "../utils/services/tracking-service"; +import { makeTestWallet } from "../utils/services/wallet-service"; +import { makeTestNavigation } from "../utils/services/widget-navigation"; + +const address = Schema.decodeSync(WalletAddress)( + "0x0000000000000000000000000000000000000001" +); +const otherAddress = Schema.decodeSync(WalletAddress)( + "0x0000000000000000000000000000000000000002" +); + +const connectedWalletState = (scope: WalletScopeKey): WalletState => + makeConnectedWalletState(scope); + +const connectingWalletState = (scope: WalletScopeKey): WalletState => + makeConnectingWalletState(scope); +const walletScope = new WalletScopeKey({ address, network: "base" }); + +const intake: BorrowTransactionFlowIntake = { + command: { + action: "borrow", + address, + args: { marketId: Schema.decodeSync(MarketId)("market-1") }, + integrationId: Schema.decodeSync(IntegrationId)("provider-1"), + }, + entry: { _tag: "BorrowEntry" }, + summary: { + action: "borrow", + borrowAmount: "1", + existingCollateralUsd: "100", + existingDebtUsd: "0", + loanTokenSymbol: "USDC", + marketLabel: "USDC market", + network: "base", + projectedCollateralUsd: "100", + projectedDebtUsd: "1", + providerName: "Provider", + riskStatus: "unavailable", + warnings: [], + }, +}; + +const transaction = (id = "tx-1"): Transaction => + Schema.decodeSync(Transaction)({ + address, + chainId: "8453", + id, + network: "base", + signablePayload: "0x00", + signingFormat: "EVM_TRANSACTION", + status: "WAITING_FOR_SIGNATURE", + type: "BORROW", + }); + +const action = (status = "CREATED") => + Schema.decodeUnknownSync(Action)({ + action: "borrow", + address, + createdAt: "2026-01-01T00:00:00.000Z", + currentStep: 1, + hasNextStep: false, + id: "action-1", + integrationId: "provider-1", + rawArguments: intake.command.args, + status, + totalSteps: 1, + transactions: [Schema.encodeSync(Transaction)(transaction())], + }); + +type ServiceOverrides = Readonly<{ + readonly borrowEnabled?: boolean; + readonly execute?: WidgetNavigation["Service"]["execute"]; + readonly executeAction?: BorrowOperations["Service"]["executeAction"]; + readonly makeWorkflow?: TransactionWorkflowService["Service"]["make"]; +}>; + +const makeBorrowFlowTestLayer = Effect.fn("makeBorrowFlowTestLayer")(function* ( + initialWalletState: WalletState, + overrides: ServiceOverrides = {} +) { + const navigation = yield* makeTestNavigation( + overrides.execute ? { execute: overrides.execute } : {} + ); + const tracking = yield* makeTestTracking(); + const wallet = yield* makeTestWallet({ initialState: initialWalletState }); + const dependencies = Layer.mergeAll( + WidgetConfigService.layer({ + apiKey: "test-api-key", + borrowEnabled: overrides.borrowEnabled ?? true, + dashboardVariant: true, + variant: "default", + }), + navigation.layer, + wallet.layer, + Layer.succeed( + BorrowOperations, + BorrowOperations.of({ + executeAction: + overrides.executeAction ?? (() => Effect.succeed(action())), + } as never) + ), + tracking.layer, + Layer.succeed( + TransactionWorkflowService, + TransactionWorkflowService.of({ + make: + overrides.makeWorkflow ?? + ((input) => + Effect.succeed({ + dispatch: () => Effect.void, + states: Stream.succeed(initializeTransactionWorkflow(input)), + })), + }) + ) + ); + return { + layer: BorrowTransactionFlowService.layer.pipe(Layer.provide(dependencies)), + setWalletState: wallet.setState, + } as const; +}); + +const acquireStartedSession = Effect.fn("test.acquireStartedBorrowSession")( + function* (service: BorrowTransactionFlowService["Service"]) { + const started = yield* service.start(intake); + if (started._tag !== "Started") { + return yield* Effect.die("Expected a started Borrow Flow Session"); + } + const acquired = yield* service.acquireSession(started.session); + if (acquired._tag !== "Acquired") { + return yield* Effect.die("Expected an acquired Borrow Flow Session"); + } + return { captured: started.session, session: acquired.session } as const; + } +); + +describe("BorrowTransactionFlowService", () => { + it.effect( + "creates a fresh Session and derives Review navigation from a copied intake", + () => + Effect.gen(function* () { + const commands: Array = []; + const flow = yield* makeBorrowFlowTestLayer( + connectingWalletState(walletScope), + { + execute: (command) => + Effect.sync(() => { + commands.push(command); + }), + } + ); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const first = yield* service.start(intake); + const second = yield* service.start(intake); + return { first, second }; + }) + ).pipe(Effect.provide(flow.layer)); + + expect(result.first).toMatchObject({ + _tag: "Started", + session: { epoch: 1 }, + }); + expect(result.second).toMatchObject({ + _tag: "Started", + session: { epoch: 2 }, + }); + expect( + result.second._tag === "Started" && result.second.session.intake + ).not.toBe(intake); + expect(commands).toEqual([ + { _tag: "Push", path: toWidgetPath("/borrow/review") }, + { _tag: "Push", path: toWidgetPath("/borrow/review") }, + ]); + }) + ); + + it.effect( + "abandons the replacement Session when its derived Review navigation fails", + () => + Effect.gen(function* () { + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope), + { + execute: () => + Effect.fail(new WidgetNavigationError({ cause: "blocked" })), + } + ); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const failed = yield* Effect.exit(service.start(intake)); + const current = yield* service.currentSession.pipe(Stream.runHead); + return { current, failed }; + }) + ).pipe(Effect.provide(flow.layer)); + + expect(result.failed._tag).toBe("Failure"); + expect(result.current).toEqual(Option.some(null)); + }) + ); + + it.effect( + "finishes a committed Start when its caller is interrupted during navigation", + () => + Effect.gen(function* () { + const navigationStarted = yield* Deferred.make(); + const navigationRelease = yield* Deferred.make(); + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope), + { + execute: () => + Deferred.succeed(navigationStarted, undefined).pipe( + Effect.andThen(Deferred.await(navigationRelease)) + ), + } + ); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const start = yield* service + .start(intake) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(navigationStarted); + const interrupt = yield* Fiber.interrupt(start).pipe( + Effect.forkChild({ startImmediately: true }) + ); + yield* Effect.yieldNow; + const duringInterrupt = yield* service.currentSession.pipe( + Stream.runHead + ); + yield* Deferred.succeed(navigationRelease, undefined); + yield* Fiber.join(interrupt); + const afterNavigation = yield* service.currentSession.pipe( + Stream.runHead + ); + return { afterNavigation, duringInterrupt }; + }) + ).pipe(Effect.provide(flow.layer)); + + expect(result.duringInterrupt).toMatchObject({ + _tag: "Some", + value: { epoch: 1 }, + }); + expect(result.afterNavigation).toMatchObject({ + _tag: "Some", + value: { epoch: 1 }, + }); + }) + ); + + it.effect( + "rejects disabled or non-owning Starts and clears an owner-invalidated Session", + () => + Effect.gen(function* () { + const disabledFlow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope), + { borrowEnabled: false } + ); + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope) + ); + const disabled = yield* Effect.scoped( + Effect.gen(function* () { + return yield* (yield* BorrowTransactionFlowService).start(intake); + }) + ).pipe(Effect.provide(disabledFlow.layer)); + expect(disabled).toEqual({ _tag: "RejectedDisabled" }); + + const result = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const started = yield* service.start(intake); + yield* flow.setWalletState(connectingWalletState(walletScope)); + const retained = yield* service.currentSession.pipe(Stream.runHead); + yield* flow.setWalletState( + connectedWalletState( + new WalletScopeKey({ address: otherAddress, network: "base" }) + ) + ); + const cleared = yield* service.currentSession.pipe( + Stream.filter((current) => current === null), + Stream.runHead + ); + const rejected = yield* service.start(intake); + return { cleared, rejected, retained, started }; + }) + ).pipe(Effect.provide(flow.layer)); + expect(result.started._tag).toBe("Started"); + expect(result.retained).toMatchObject({ + _tag: "Some", + value: { epoch: 1 }, + }); + expect(result.cleared).toEqual(Option.some(null)); + expect(result.rejected).toEqual({ _tag: "RejectedOwner" }); + }) + ); + + it.effect( + "rolls back only the active reservation on failed Steps navigation and retries Confirm fully", + () => + Effect.gen(function* () { + const commands: Array = []; + let failSteps = true; + const executeAction = vi.fn(() => Effect.succeed(action())); + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope), + { + execute: (command) => + Effect.suspend(() => { + commands.push(command); + if ( + command._tag === "Push" && + command.path.endsWith("/steps") && + failSteps + ) { + failSteps = false; + return Effect.fail( + new WidgetNavigationError({ cause: "blocked" }) + ); + } + return Effect.void; + }), + executeAction, + } + ); + + const result = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const { session } = yield* acquireStartedSession(service); + const review = yield* session.acquireReview(); + const failed = yield* Effect.exit(review.confirm()); + const retried = yield* review.confirm(); + const duplicate = yield* review.confirm(); + return { duplicate, failed, retried }; + }) + ).pipe(Effect.provide(flow.layer)); + + expect(result.failed._tag).toBe("Failure"); + expect(result.retried).toEqual({ _tag: "Confirmed" }); + expect(result.duplicate).toEqual({ _tag: "RejectedAlreadyReserved" }); + expect(executeAction).toHaveBeenCalledTimes(2); + expect(commands.map((command) => command._tag)).toEqual([ + "Push", + "Push", + "Push", + ]); + }) + ); + + it.effect.each(["FAILED", "SUCCESS"])( + "rejects immediately terminal %s actions as typed creation failures", + (status) => + Effect.gen(function* () { + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope), + { + executeAction: () => Effect.succeed(action(status)), + } + ); + const error = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const { session } = yield* acquireStartedSession(service); + const review = yield* session.acquireReview(); + return yield* Effect.flip(review.confirm()); + }) + ).pipe(Effect.provide(flow.layer)); + expect(error).toBeInstanceOf(BorrowActionCreationError); + }) + ); + + it.effect("clears the reserved action when Review is acquired again", () => + Effect.gen(function* () { + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope) + ); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const { session } = yield* acquireStartedSession(service); + const review = yield* session.acquireReview(); + const confirmed = yield* review.confirm(); + yield* session.acquireReview(); + const execution = yield* session.acquireExecution(); + return { confirmed, execution }; + }) + ).pipe(Effect.provide(flow.layer)); + + expect(result).toEqual({ + confirmed: { _tag: "Confirmed" }, + execution: { _tag: "RejectedNoReservation" }, + }); + }) + ); + + it.effect( + "keeps a committed Confirm reservation when the Review Scope closes during navigation", + () => + Effect.gen(function* () { + const navigationStarted = yield* Deferred.make(); + const navigationRelease = yield* Deferred.make(); + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope), + { + execute: (command) => + command._tag === "Push" && + command.path === toWidgetPath("/borrow/steps") + ? Deferred.succeed(navigationStarted, undefined).pipe( + Effect.andThen(Deferred.await(navigationRelease)) + ) + : Effect.void, + } + ); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const { session } = yield* acquireStartedSession(service); + const reviewScope = yield* Scope.make(); + const review = yield* session + .acquireReview() + .pipe(Effect.provideService(Scope.Scope, reviewScope)); + const confirmation = yield* review.confirm().pipe(Effect.forkChild); + yield* Deferred.await(navigationStarted); + const close = yield* Scope.close(reviewScope, Exit.void).pipe( + Effect.forkChild({ startImmediately: true }) + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(navigationRelease, undefined); + yield* Fiber.join(close); + yield* Fiber.await(confirmation); + return yield* session.acquireExecution(); + }) + ).pipe(Effect.provide(flow.layer)); + + expect(result._tag).toBe("Acquired"); + }) + ); + + it.effect("validates authoritative completion before Finish navigation", () => + Effect.gen(function* () { + const commands: Array = []; + const workflowState = + yield* SubscriptionRef.make( + initializeTransactionWorkflow( + new BorrowTransactionWorkflowInput({ + action: action(), + walletScope, + }) + ) + ); + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope), + { + execute: (command) => + Effect.sync(() => { + commands.push(command); + }), + makeWorkflow: (_input: TransactionWorkflowInput) => + Effect.succeed({ + dispatch: () => Effect.void, + states: SubscriptionRef.changes(workflowState), + }), + } + ); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const { session } = yield* acquireStartedSession(service); + const review = yield* session.acquireReview(); + yield* review.confirm(); + const acquired = yield* session.acquireExecution(); + if (acquired._tag !== "Acquired") { + return yield* Effect.die("Expected Execution acquisition"); + } + const early = yield* acquired.execution.finish(); + const current = yield* SubscriptionRef.get(workflowState); + const completed: TransactionWorkflowState = { + ...current, + _tag: "Completed", + }; + yield* SubscriptionRef.set(workflowState, completed); + const accepted = yield* acquired.execution.finish(); + return { accepted, early }; + }) + ).pipe(Effect.provide(flow.layer)); + + expect(result.early).toEqual({ _tag: "RejectedNotCompleted" }); + expect(result.accepted).toEqual({ _tag: "Accepted" }); + expect(commands).toContainEqual({ + _tag: "Replace", + path: toWidgetPath("/borrow"), + }); + }) + ); + + it.effect("does not let a released stale Session clear its replacement", () => + Effect.gen(function* () { + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope) + ); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const first = yield* service.start(intake); + if (first._tag !== "Started") return yield* Effect.die("first"); + const firstScope = yield* Scope.make(); + yield* service + .acquireSession(first.session) + .pipe(Effect.provideService(Scope.Scope, firstScope)); + const second = yield* service.start(intake); + if (second._tag !== "Started") return yield* Effect.die("second"); + yield* Scope.close(firstScope, Exit.void); + const current = yield* service.currentSession.pipe(Stream.runHead); + return { current, second }; + }) + ).pipe(Effect.provide(flow.layer)); + expect(result.current).toMatchObject({ + _tag: "Some", + value: { epoch: result.second.session.epoch }, + }); + }) + ); + + it.effect( + "retries automatic completion navigation every 100 milliseconds", + () => + Effect.gen(function* () { + let completionAttempts = 0; + const flow = yield* makeBorrowFlowTestLayer( + connectedWalletState(walletScope), + { + execute: (command) => { + if ( + command._tag !== "Replace" || + command.path !== toWidgetPath("/borrow/complete") + ) { + return Effect.void; + } + completionAttempts += 1; + return completionAttempts < 3 + ? Effect.fail(new WidgetNavigationError({ cause: "blocked" })) + : Effect.void; + }, + makeWorkflow: (input) => + Effect.succeed({ + dispatch: () => Effect.void, + states: Stream.succeed({ + ...initializeTransactionWorkflow(input), + _tag: "Completed" as const, + }), + }), + } + ); + const attempts = yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* BorrowTransactionFlowService; + const { session } = yield* acquireStartedSession(service); + const review = yield* session.acquireReview(); + yield* review.confirm(); + const execution = yield* session.acquireExecution(); + if (execution._tag !== "Acquired") { + return yield* Effect.die("Expected Execution acquisition"); + } + yield* Effect.yieldNow; + const initial = completionAttempts; + yield* TestClock.adjust("99 millis"); + const beforeBoundary = completionAttempts; + yield* TestClock.adjust("1 millis"); + const firstRetry = completionAttempts; + yield* TestClock.adjust("100 millis"); + return { + beforeBoundary, + firstRetry, + initial, + success: completionAttempts, + }; + }) + ).pipe(Effect.provide(Layer.mergeAll(TestClock.layer(), flow.layer))); + + expect(attempts).toEqual({ + beforeBoundary: 1, + firstRetry: 2, + initial: 1, + success: 3, + }); + }) + ); +}); diff --git a/packages/widget/tests/features/classic-flow-atoms.test.ts b/packages/widget/tests/features/classic-flow-atoms.test.ts new file mode 100644 index 000000000..a1c739cc5 --- /dev/null +++ b/packages/widget/tests/features/classic-flow-atoms.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { Effect, Layer, Schema, SubscriptionRef } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; +import { walletRuntime } from "../../src/app/runtime/wallet-runtime"; +import { WalletAddress } from "../../src/domain/identity/identifiers"; +import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; +import type { + ClassicFlowSession, + ClassicTransactionFlowIntake, + StartClassicTransactionFlow, +} from "../../src/features/classic-transaction-flow/model/classic-transaction-flow"; +import { + currentClassicFlowSessionAtom, + isActiveClassicTransactionFlowPathAtom, + startClassicTransactionFlowAtom, +} from "../../src/features/classic-transaction-flow/state/atoms/classic-flow"; +import { currentClassicFlowSessionRootAtom } from "../../src/features/classic-transaction-flow/state/atoms/classic-flow-session"; +import type { ClassicFlowSessionHandle } from "../../src/features/classic-transaction-flow/state/orchestration/classic-flow-session"; +import { ClassicTransactionFlowService } from "../../src/features/classic-transaction-flow/state/orchestration/classic-transaction-flow-service"; +import { toWidgetPath } from "../../src/services/navigation/widget-navigation"; +import { yieldApiYieldFixture } from "../fixtures"; + +const address = Schema.decodeSync(WalletAddress)( + "0x1234567890123456789012345678901234567890" +); +const walletScope = new WalletScopeKey({ address, network: "ethereum" }); + +const makeEnterIntake = (): Extract< + ClassicTransactionFlowIntake, + { readonly _tag: "Enter" } +> => { + const selectedStake = yieldApiYieldFixture(); + return { + _tag: "Enter", + gasFeeToken: selectedStake.mechanics.gasFeeToken, + providersDetails: [], + request: { + address, + arguments: { amount: "1" }, + yieldId: selectedStake.id, + }, + selectedStake, + selectedToken: selectedStake.token, + selectedValidators: new Map(), + walletScope, + }; +}; + +const makeSession = ( + intake: ClassicTransactionFlowIntake, + epoch: number +): ClassicFlowSession => { + const mount = (() => { + switch (intake._tag) { + case "Enter": + return { _tag: "Earn" } as const; + case "Exit": + return { + _tag: "PositionExit", + balanceId: "balance", + integrationId: intake.integration.id, + } as const; + case "Manage": + return { + _tag: "PositionManage", + balanceId: "balance", + integrationId: intake.integration.id, + } as const; + case "YieldActionContinuation": + return { + _tag: "YieldActionContinuation", + } as const; + } + })(); + + return { + destination: { + completePath: toWidgetPath("/complete"), + reviewPath: toWidgetPath("/review"), + stepsPath: toWidgetPath("/steps"), + }, + epoch, + intake, + mount, + }; +}; + +const makeSessionHandle = ( + session: ClassicFlowSession +): ClassicFlowSessionHandle => ({ + acquireExecution: () => + Effect.succeed({ _tag: "RejectedNoReservation" } as const), + acquireReview: () => Effect.die("Review is outside this Atom bridge test"), + intake: session.intake, +}); + +describe("Classic Flow Atom bridge", () => { + it.effect( + "looks up the service once, projects current state, and binds Session acquisition to the root Atom lifetime", + () => + Effect.gen(function* () { + const current = yield* SubscriptionRef.make( + null + ); + const probes = { acquired: 0, released: 0 }; + const startInputs: Array = []; + const service = ClassicTransactionFlowService.of({ + acquireSession: (session) => + Effect.acquireRelease( + Effect.sync(() => { + probes.acquired += 1; + return { + _tag: "Acquired", + session: makeSessionHandle(session), + } as const; + }), + () => + Effect.sync(() => { + probes.released += 1; + }).pipe(Effect.andThen(SubscriptionRef.set(current, null))) + ), + currentSession: SubscriptionRef.changes(current), + start: (input) => + Effect.gen(function* () { + startInputs.push(input); + const session = makeSession(input.intake, 1); + yield* SubscriptionRef.set(current, session); + return { _tag: "Started", session } as const; + }), + }); + const registry = AtomRegistry.make({ + initialValues: [ + Atom.initialValue( + walletRuntime.layer, + Layer.succeed(ClassicTransactionFlowService, service) as never + ), + ], + }); + const intake = makeEnterIntake(); + const command = { intake, mount: { _tag: "Earn" } } as const; + + registry.set(startClassicTransactionFlowAtom, command); + yield* Effect.promise(() => + vi.waitFor(() => + expect(registry.get(currentClassicFlowSessionAtom)?.epoch).toBe(1) + ) + ); + expect(startInputs).toEqual([command]); + expect( + registry.get(isActiveClassicTransactionFlowPathAtom("/review")) + ).toBe(true); + + const rootAtom = registry.get(currentClassicFlowSessionRootAtom); + if (!rootAtom) + throw new Error("Expected a Classic Flow Session root Atom"); + const releaseRoot = registry.mount(rootAtom); + yield* Effect.promise(() => + vi.waitFor(() => expect(probes.acquired).toBe(1)) + ); + + releaseRoot(); + yield* Effect.promise(() => + vi.waitFor(() => expect(probes.released).toBe(1)) + ); + yield* Effect.promise(() => + vi.waitFor(() => + expect(registry.get(currentClassicFlowSessionAtom)).toBeNull() + ) + ); + }) + ); +}); diff --git a/packages/widget/tests/features/classic-flow-navigation.dom.test.tsx b/packages/widget/tests/features/classic-flow-navigation.dom.test.tsx new file mode 100644 index 000000000..cd3553e74 --- /dev/null +++ b/packages/widget/tests/features/classic-flow-navigation.dom.test.tsx @@ -0,0 +1,691 @@ +import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Effect, Layer, Schema, Stream, SubscriptionRef } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { HttpResponse, http } from "msw"; +import { act, useEffect, useState } from "react"; +import { + createMemoryRouter, + type DataRouter, + type NavigateFunction, + Route, + RouterProvider, + Routes, + useLocation, + useNavigate, +} from "react-router"; +import { ApplicationRouteContentProvider } from "../../src/app/composition/application-route-content"; +import { applicationRoutes } from "../../src/app/routes/application-routes"; +import { applicationBaseRuntime } from "../../src/app/runtime/application-base-runtime"; +import { walletRuntime } from "../../src/app/runtime/wallet-runtime"; +import { ActionCommand } from "../../src/domain/action/models"; +import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; +import { + isActiveClassicTransactionFlowPathAtom, + startClassicTransactionFlowAtom, +} from "../../src/features/classic-transaction-flow/index"; +import type { ClassicTransactionFlowIntake } from "../../src/features/classic-transaction-flow/model/classic-transaction-flow"; +import { + ClassicFlowExecutionScope, + ClassicFlowReviewScope, + ClassicFlowRoute, + useClassicFlowExecution, + useClassicFlowReview, + useClassicFlowSession, +} from "../../src/features/classic-transaction-flow/react/classic-flow-route"; +import { ClassicTransactionFlowService } from "../../src/features/classic-transaction-flow/state/orchestration/classic-transaction-flow-service"; +import { walletScopeAtom } from "../../src/features/wallet/index"; +import { WalletScopeRoute } from "../../src/features/wallet/react/wallet-scope-route"; +import { WidgetConfigService } from "../../src/services/config/widget-config"; +import { ApplicationRouter } from "../../src/services/navigation/application-router"; +import { makeWidgetNavigation } from "../../src/services/navigation/widget-navigation"; +import { TransactionWorkflowService } from "../../src/services/transaction-workflow/transaction-workflow-service"; +import { WalletService } from "../../src/services/wallet/wallet-service"; +import { + disconnectedLedgerConnectorState, + disconnectedNormalizedWalletState, + type NormalizedWalletState, + type WalletState, +} from "../../src/services/wallet/wallet-state"; +import { yieldApiActionFixture, yieldApiYieldFixture } from "../fixtures"; +import { makeConnectedWalletState } from "../fixtures/wallet-state"; +import { makeTestTracking } from "../utils/services/tracking-service"; +import { makeTestWallet } from "../utils/services/wallet-service"; +import { makeTestNavigation } from "../utils/services/widget-navigation"; +import { makeTestStakeKitApiLayer } from "../utils/stakekit-api-layer"; +import { describe, expect, it, vi } from "../utils/test-extend.dom.ts"; +import { render } from "../utils/test-utils.dom.tsx"; + +const yieldApiUrl = "https://yield.example.com"; +const legacyApiUrl = "https://api.example.com"; +const command = Schema.decodeSync(ActionCommand)({ + address: "0xWallet", + yieldId: "ethereum-eth-native-staking", +}); +const selectedStake = yieldApiYieldFixture({ id: command.yieldId }); +const walletScope = new WalletScopeKey({ + address: command.address, + network: "ethereum", +}); +const connectedWalletState: NormalizedWalletState = { + additionalAddresses: null, + address: command.address, + chain: {} as never, + connector: {} as never, + connectorChains: [], + isLedgerLive: false, + isLedgerLiveAccountPlaceholder: false, + ledgerAccounts: [], + network: "ethereum", + status: "connected", +}; +const disconnectTestWalletAtom = walletRuntime + .fn(() => WalletService.use((wallet) => wallet.logout)) + .pipe(Atom.withLabel("disconnectTestWalletAtom")); +const walletLayer = Layer.merge( + Layer.unwrap( + Effect.gen(function* () { + const state = yield* SubscriptionRef.make( + makeConnectedWalletState(walletScope) + ); + const wallet = yield* makeTestWallet({ + logout: SubscriptionRef.set(state, { + connection: disconnectedNormalizedWalletState, + ledger: disconnectedLedgerConnectorState, + }), + state, + }); + return wallet.layer; + }) + ), + Layer.succeed( + TransactionWorkflowService, + TransactionWorkflowService.of({ + make: () => + Effect.succeed({ + dispatch: () => Effect.void, + states: Stream.never, + }), + }) + ) +); +const apiLayer = makeTestStakeKitApiLayer({ + apiKey: "test-key", + baseUrl: legacyApiUrl, + borrowApiUrl: "https://borrow.example.com", + yieldsApiUrl: yieldApiUrl, +}); +const trackingLayer = Layer.unwrap( + makeTestTracking().pipe(Effect.map((tracking) => tracking.layer)) +); + +const intake: ClassicTransactionFlowIntake = { + _tag: "Enter", + gasFeeToken: selectedStake.mechanics.gasFeeToken, + providersDetails: [], + request: command, + selectedStake, + selectedToken: selectedStake.token, + selectedValidators: new Map(), + walletScope, +}; + +const executionFacades: Array = []; +const getExecutionFacadeId = (facade: object) => { + const existing = executionFacades.indexOf(facade); + if (existing >= 0) return existing + 1; + executionFacades.push(facade); + return executionFacades.length; +}; + +type TestNavigationChannel = { + navigate: NavigateFunction | null; +}; +const testNavigationChannel: TestNavigationChannel = { navigate: null }; + +const TestNavigationBridge = ({ + channel, +}: { + readonly channel: TestNavigationChannel; +}) => { + const navigate = useNavigate(); + useEffect(() => { + // biome-ignore lint/nursery/useReactCompiler: Test bridge intentionally publishes navigation outside React. + channel.navigate = navigate; + return () => { + channel.navigate = null; + }; + }, [channel, navigate]); + return null; +}; + +const StartPage = () => { + const isActive = useAtomValue( + isActiveClassicTransactionFlowPathAtom("/review") + ); + const start = useAtomSet(startClassicTransactionFlowAtom); + const navigate = useNavigate(); + + return ( + <> + + + + + ); +}; + +const ReviewPage = () => { + useClassicFlowSession(); + const reviewFacade = useClassicFlowReview(); + const navigate = useNavigate(); + const review = useAtomValue(reviewFacade.reviewViewAtom); + const confirm = useAtomSet(reviewFacade.confirmAtom); + const start = useAtomSet(startClassicTransactionFlowAtom); + + return ( + <> + present + {review.action?.id} + + {review.prices ? "ready" : "loading"} + + + + + + + ); +}; + +const StepsPage = () => { + useClassicFlowSession(); + const execution = useClassicFlowExecution(); + const back = useAtomSet(execution.backAtom); + const navigate = useNavigate(); + + return ( + <> + present + + {getExecutionFacadeId(execution)} + + + + + + + ); +}; + +const CompletePage = () => { + const execution = useClassicFlowExecution(); + const navigate = useNavigate(); + + return ( + <> + + {getExecutionFacadeId(execution)} + + + + ); +}; + +const FlowRoutes = ({ + navigationChannel, + walletState, +}: { + readonly navigationChannel: TestNavigationChannel; + readonly walletState: NormalizedWalletState; +}) => { + const location = useLocation(); + const isActive = useAtomValue( + isActiveClassicTransactionFlowPathAtom(location.pathname) + ); + const key = isActive ? "flow-session" : location.key; + + return ( + <> + + + } /> + + } + > + }> + + + + } + /> + }> + } /> + } /> + + + + + + ); +}; + +const FlowTestApp = ({ + initialPath = "/", + walletState = connectedWalletState, +}: { + readonly initialPath?: string; + readonly walletState?: NormalizedWalletState; +}) => { + const hostConfiguration = { + apiKey: "test-key", + baseUrl: legacyApiUrl, + variant: "default" as const, + yieldsApiUrl: yieldApiUrl, + }; + const [router] = useState(() => + createMemoryRouter([...applicationRoutes], { + initialEntries: [initialPath], + }) + ); + const applicationRouterLayer = Layer.merge( + Layer.succeed( + ApplicationRouter, + ApplicationRouter.of({ pathnames: Stream.never, router }) + ), + WidgetConfigService.layer(hostConfiguration) + ); + const navigation = makeWidgetNavigation({ + back: () => + Effect.sync(() => { + testNavigationChannel.navigate?.(-1); + }), + push: (path, options) => + Effect.sync(() => { + if (!testNavigationChannel.navigate) { + throw new Error("Test navigation bridge is unavailable"); + } + testNavigationChannel.navigate(path, { state: options?.state }); + }), + replace: (path, options) => + Effect.sync(() => { + testNavigationChannel.navigate?.(path, { + replace: true, + state: options?.state, + }); + }), + }); + const navigationLayer = Layer.unwrap( + makeTestNavigation({ execute: navigation.execute }).pipe( + Effect.map((testNavigation) => testNavigation.layer) + ) + ); + const classicDependencies = Layer.mergeAll( + apiLayer, + navigationLayer, + trackingLayer, + walletLayer + ); + const classicWalletLayer = Layer.merge( + classicDependencies, + ClassicTransactionFlowService.layer.pipe(Layer.provide(classicDependencies)) + ); + return ( + + + + + ); +}; + +const WalletStateBridge = () => { + const disconnectWallet = useAtomSet(disconnectTestWalletAtom); + return ( +