diff --git a/crates/settings/src/gui.rs b/crates/settings/src/gui.rs index 773f88809..bd1ac4a48 100644 --- a/crates/settings/src/gui.rs +++ b/crates/settings/src/gui.rs @@ -263,7 +263,9 @@ impl_all_wasm_traits!(Gui); pub struct NameAndDescription { pub name: String, pub description: String, + pub short_description: Option, } + #[cfg(target_family = "wasm")] impl_all_wasm_traits!(NameAndDescription); @@ -376,7 +378,17 @@ impl Gui { Some("gui".to_string()), )?; - return Ok(NameAndDescription { name, description }); + let short_description = require_string( + get_hash_value(gui, "short-description", Some("gui".to_string()))?, + None, + Some("gui".to_string()), + )?; + + return Ok(NameAndDescription { + name, + description, + short_description: Some(short_description), + }); } } Err(YamlError::Field { @@ -422,8 +434,14 @@ impl Gui { Some(location.clone()), )?; - deployment_details - .insert(deployment_key, NameAndDescription { name, description }); + deployment_details.insert( + deployment_key, + NameAndDescription { + name, + description, + short_description: None, + }, + ); } } } @@ -1474,8 +1492,8 @@ gui: - binding: test name: test presets: - - value: - - test + - value: + wrong: map "#; let error = Gui::parse_from_yaml_optional( vec![get_document(&format!("{yaml_prefix}{yaml}"))], diff --git a/crates/settings/src/yaml/dotrain.rs b/crates/settings/src/yaml/dotrain.rs index 74f0a8dc1..80ced18f6 100644 --- a/crates/settings/src/yaml/dotrain.rs +++ b/crates/settings/src/yaml/dotrain.rs @@ -204,6 +204,7 @@ mod tests { gui: name: Test gui description: Test description + short-description: Test short description deployments: deployment1: name: Test deployment diff --git a/package-lock.json b/package-lock.json index 58ffb73bd..b7b44e132 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "flowbite": "^2.2.1", "flowbite-svelte": "^0.44.21", "flowbite-svelte-icons": "^0.4.5", + "svelte-markdown": "^0.4.1", "wagmi": "^2.14.7" }, "devDependencies": { @@ -28,6 +29,7 @@ "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@tailwindcss/typography": "^0.5.16", "@tanstack/svelte-query": "^5.59.20", "@testing-library/jest-dom": "^6.4.2", "@testing-library/svelte": "^5.1.0", @@ -49,7 +51,7 @@ "jsdom": "^24.0.0", "lodash": "^4.17.21", "mockttp": "^3.15.1", - "postcss": "^8.4.32", + "postcss": "^8.5.2", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.6", "prettier-plugin-tailwindcss": "^0.6.5", @@ -57,7 +59,7 @@ "svelte": "^4.2.7", "svelte-check": "^3.6.0", "tailwind-merge": "^2.5.4", - "tailwindcss": "^3.4.9", + "tailwindcss": "^3.4.17", "ts-node": "^10.9.1", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", @@ -4676,6 +4678,36 @@ "vite": "^5.0.0" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tanstack/query-core": { "version": "5.59.20", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.20.tgz", @@ -12172,6 +12204,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12289,12 +12322,26 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "license": "MIT" }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -12807,9 +12854,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -16599,9 +16646,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", + "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", "funding": [ { "type": "opencollective", @@ -16618,7 +16665,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -18650,33 +18697,33 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", - "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", - "chokidar": "^3.5.3", + "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.3.0", + "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.0", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", @@ -18722,6 +18769,18 @@ "node": ">= 6" } }, + "node_modules/tailwindcss/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/tailwindcss/node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -18769,18 +18828,6 @@ } } }, - "node_modules/tailwindcss/node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", - "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/tailwindcss/node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", diff --git a/package.json b/package.json index d97f8df23..b7f7b38a2 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@tailwindcss/typography": "^0.5.16", "@tanstack/svelte-query": "^5.59.20", "@testing-library/jest-dom": "^6.4.2", "@testing-library/svelte": "^5.1.0", @@ -52,7 +53,7 @@ "jsdom": "^24.0.0", "lodash": "^4.17.21", "mockttp": "^3.15.1", - "postcss": "^8.4.32", + "postcss": "^8.5.2", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.6", "prettier-plugin-tailwindcss": "^0.6.5", @@ -60,7 +61,7 @@ "svelte": "^4.2.7", "svelte-check": "^3.6.0", "tailwind-merge": "^2.5.4", - "tailwindcss": "^3.4.9", + "tailwindcss": "^3.4.17", "ts-node": "^10.9.1", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", @@ -83,6 +84,7 @@ "flowbite": "^2.2.1", "flowbite-svelte": "^0.44.21", "flowbite-svelte-icons": "^0.4.5", + "svelte-markdown": "^0.4.1", "wagmi": "^2.14.7" } } diff --git a/packages/orderbook/test/js_api/gui.test.ts b/packages/orderbook/test/js_api/gui.test.ts index 192a24c70..701af6781 100644 --- a/packages/orderbook/test/js_api/gui.test.ts +++ b/packages/orderbook/test/js_api/gui.test.ts @@ -23,10 +23,12 @@ const guiConfig = ` gui: name: Fixed limit description: Fixed limit order strategy + short-description: Buy WETH with USDC on Base. deployments: some-deployment: name: Buy WETH with USDC on Base. description: Buy WETH with USDC for fixed price on Base network. + short-description: Buy WETH with USDC on Base. deposits: - token: token1 min: 0 @@ -374,6 +376,7 @@ describe('Rain Orderbook JS API Package Bindgen Tests - Gui', async function () await DotrainOrderGui.getStrategyDetails(dotrainWithGui); assert.equal(strategyDetails.name, 'Fixed limit'); assert.equal(strategyDetails.description, 'Fixed limit order strategy'); + assert.equal(strategyDetails.short_description, 'Buy WETH with USDC on Base.'); }); it('should get deployment details', async () => { diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index b37deba33..9225134a3 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -12,6 +12,12 @@ "import": "./dist/index.js", "require": "./dist/index.js", "svelte": "./dist/index.js" + }, + "./services": { + "types": "./dist/services/index.d.ts", + "import": "./dist/services/index.js", + "require": "./dist/services/index.js", + "svelte": "./dist/services/index.js" } }, "scripts": { diff --git a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts index 464e815a0..70b2c63bf 100644 --- a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts +++ b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import DeploymentSteps from '../lib/components/deployment/DeploymentSteps.svelte'; -import { DotrainOrderGui } from '@rainlanguage/orderbook/js_api'; +import { DotrainOrderGui, type Scenario } from '@rainlanguage/orderbook/js_api'; import type { ComponentProps } from 'svelte'; import { writable } from 'svelte/store'; @@ -14,7 +14,8 @@ export type DeploymentStepsProps = ComponentProps; vi.mock('@rainlanguage/orderbook/js_api', () => ({ DotrainOrderGui: { - chooseDeployment: vi.fn() + chooseDeployment: vi.fn(), + getStrategyDetails: vi.fn() } })); @@ -288,7 +289,8 @@ subgraphs: orderbooks: flare: - address: 0xCEe8Cd002F151A536394E564b84076c41bBBcD4d + id: 'flare', + address: '0x0' deployers: flare: @@ -493,7 +495,6 @@ val: multiplier multiplier multiplier - multiplier ); #set-last-trade @@ -569,6 +570,51 @@ min-trade-amount: mul(min-amount 0.9), :call<'set-cost-basis-io-ratio>();`; describe('DeploymentSteps', () => { + const mockDeployment = { + key: 'flare-sflr-wflr', + name: 'SFLR<>WFLR on Flare', + description: 'Rotate sFLR (Sceptre staked FLR) and WFLR on Flare.', + deposits: [], + fields: [], + select_tokens: [], + deployment: { + key: 'flare-sflr-wflr', + scenario: { + key: 'flare', + bindings: {} + } as Scenario, + order: { + key: 'flare-sflr-wflr', + network: { + key: 'flare', + 'chain-id': 14, + 'network-id': 14, + rpc: 'https://rpc.ankr.com/flare', + label: 'Flare', + currency: 'FLR' + }, + deployer: { + key: 'flare', + network: { + key: 'flare', + 'chain-id': 14, + 'network-id': 14, + rpc: 'https://rpc.ankr.com/flare', + label: 'Flare', + currency: 'FLR' + }, + address: '0x0' + }, + orderbook: { + id: 'flare', + address: '0x0' + }, + inputs: [], + outputs: [] + } + } + }; + beforeEach(() => { vi.clearAllMocks(); }); @@ -579,16 +625,10 @@ describe('DeploymentSteps', () => { getTokenInfo: vi.fn() }); - const deploymentDetails = { - name: 'SFLR<>WFLR on Flare', - description: 'Rotate sFLR (Sceptre staked FLR) and WFLR on Flare.' - }; - render(DeploymentSteps, { props: { dotrain, - deployment: 'flare-sflr-wflr', - deploymentDetails, + deployment: mockDeployment, wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), @@ -599,9 +639,6 @@ describe('DeploymentSteps', () => { await waitFor(() => { expect(screen.getByText('SFLR<>WFLR on Flare')).toBeInTheDocument(); - expect( - screen.getByText('Rotate sFLR (Sceptre staked FLR) and WFLR on Flare.') - ).toBeInTheDocument(); }); }); @@ -615,8 +652,7 @@ describe('DeploymentSteps', () => { render(DeploymentSteps, { props: { dotrain, - deployment: 'flare-sflr-wflr', - deploymentDetails: { name: 'Deployment 1', description: 'Description 1' }, + deployment: mockDeployment, wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), @@ -641,8 +677,7 @@ describe('DeploymentSteps', () => { render(DeploymentSteps, { props: { dotrain, - deployment: 'flare-sflr-wflr', - deploymentDetails: { name: 'Deployment 1', description: 'Description 1' }, + deployment: mockDeployment, wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), @@ -677,8 +712,7 @@ describe('DeploymentSteps', () => { render(DeploymentSteps, { props: { dotrain, - deployment: 'flare-sflr-wflr', - deploymentDetails: { name: 'Deployment 1', description: 'Description 1' }, + deployment: mockDeployment, wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), @@ -711,8 +745,7 @@ describe('DeploymentSteps', () => { render(DeploymentSteps, { props: { dotrain, - deployment: 'flare-sflr-wflr', - deploymentDetails: { name: 'Deployment 1', description: 'Description 1' }, + deployment: mockDeployment, wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), diff --git a/packages/ui-components/src/__tests__/DeploymentsSection.test.ts b/packages/ui-components/src/__tests__/DeploymentsSection.test.ts index e0b81e838..7a0145a43 100644 --- a/packages/ui-components/src/__tests__/DeploymentsSection.test.ts +++ b/packages/ui-components/src/__tests__/DeploymentsSection.test.ts @@ -17,8 +17,11 @@ describe('DeploymentsSection', () => { it('should render deployments when data is available', async () => { const mockDeployments = new Map([ - ['key1', { name: 'Deployment 1', description: 'Description 1' }], - ['key2', { name: 'Deployment 2', description: 'Description 2' }] + [ + 'key1', + { name: 'Deployment 1', description: 'Description 1', short_description: 'Short 1' } + ], + ['key2', { name: 'Deployment 2', description: 'Description 2', short_description: 'Short 2' }] ]); vi.mocked(DotrainOrderGui.getDeploymentDetails).mockResolvedValue(mockDeployments); diff --git a/packages/ui-components/src/__tests__/StrategyPage.test.ts b/packages/ui-components/src/__tests__/StrategyPage.test.ts new file mode 100644 index 000000000..40a43d68d --- /dev/null +++ b/packages/ui-components/src/__tests__/StrategyPage.test.ts @@ -0,0 +1,196 @@ +import { render, screen, waitFor } from '@testing-library/svelte'; +import StrategyPage from '../lib/components/deployment/StrategyPage.svelte'; +import { DotrainOrderGui } from '@rainlanguage/orderbook/js_api'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +// Mock fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Mock DotrainOrderGui +vi.mock('@rainlanguage/orderbook/js_api', () => ({ + DotrainOrderGui: { + getStrategyDetails: vi.fn(), + getDeploymentDetails: vi.fn() + } +})); + +vi.mock('svelte-markdown', async () => { + const mockSvelteMarkdown = (await import('../lib/__mocks__/MockComponent.svelte')).default; + return { default: mockSvelteMarkdown }; +}); + +describe('StrategySection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders strategy details successfully with rawDotrain', async () => { + const mockDotrain = 'mock dotrain content'; + const mockStrategyDetails = { + name: 'Test Strategy', + description: 'Test Description', + short_description: 'Test Short Description' + }; + vi.mocked(DotrainOrderGui.getStrategyDetails).mockResolvedValueOnce(mockStrategyDetails); + + render(StrategyPage, { + props: { + dotrain: mockDotrain + } + }); + + await waitFor(() => { + expect(screen.getByText('Test Strategy')).toBeInTheDocument(); + expect(screen.getByText('Test Description')).toBeInTheDocument(); + }); + }); + + it('renders strategy details successfully from fetch', async () => { + const mockDotrain = 'mock dotrain content'; + const mockStrategyDetails = { + name: 'Test Strategy', + description: 'Test Description', + short_description: 'Test Short Description' + }; + + // Mock fetch response + mockFetch.mockResolvedValueOnce({ + text: () => Promise.resolve(mockDotrain) + }); + + // Mock DotrainOrderGui methods + vi.mocked(DotrainOrderGui.getStrategyDetails).mockResolvedValueOnce(mockStrategyDetails); + + render(StrategyPage, { + props: { + strategyName: 'TestStrategy', + dotrain: mockDotrain + } + }); + + await waitFor(() => { + expect(screen.getByText('Test Strategy')).toBeInTheDocument(); + expect(screen.getByText('Test Description')).toBeInTheDocument(); + }); + }); + + it('displays error message when strategy details fail', async () => { + const mockDotrain = 'mock dotrain content'; + const mockError = new Error('Failed to get strategy details'); + + // Mock fetch response + mockFetch.mockResolvedValueOnce({ + text: () => Promise.resolve(mockDotrain) + }); + + // Mock DotrainOrderGui methods + vi.mocked(DotrainOrderGui.getStrategyDetails).mockRejectedValueOnce(mockError); + + render(StrategyPage, { + props: { + strategyName: 'TestStrategy', + dotrain: mockDotrain + } + }); + + await waitFor(() => { + expect(screen.getByText('Error getting strategy details')).toBeInTheDocument(); + expect(screen.getByText('Failed to get strategy details')).toBeInTheDocument(); + }); + }); + + it('handles fetch failure', async () => { + const mockError = new Error('Failed to fetch'); + + // Mock fetch to reject + mockFetch.mockRejectedValueOnce(mockError); + + render(StrategyPage, { + props: { + strategyName: 'TestStrategy' + } + }); + + await waitFor(() => { + expect(screen.getByText('Error getting strategy details')).toBeInTheDocument(); + expect( + screen.getByText("Cannot read properties of undefined (reading 'description')") + ).toBeInTheDocument(); + }); + }); + + it('renders markdown if description is a markdown url', async () => { + const mockDotrain = 'mock dotrain content'; + const mockStrategyDetails = { + name: 'Test Strategy', + description: 'https://example.com/description.md', + short_description: 'Test Short Description' + }; + const mockMarkdownContent = '# Mock Markdown Content'; + + // First fetch for dotrain + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockDotrain) + }); + + // Second fetch for markdown content + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockMarkdownContent) + }); + + vi.mocked(DotrainOrderGui.getStrategyDetails).mockResolvedValueOnce(mockStrategyDetails); + + render(StrategyPage, { + props: { + strategyName: 'TestStrategy', + dotrain: mockDotrain + } + }); + + await waitFor(() => { + expect(screen.getByText('Test Strategy')).toBeInTheDocument(); + expect(screen.getByTestId('plain-description')).toHaveTextContent( + 'https://example.com/description.md' + ); + expect(mockFetch).toHaveBeenCalledWith('https://example.com/description.md'); + }); + }); + + it('falls back to plain text when markdown fetch fails', async () => { + const mockDotrain = 'mock dotrain content'; + const mockStrategyDetails = { + name: 'Test Strategy', + description: 'https://example.com/description.md', + short_description: 'Test Short Description' + }; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockDotrain) + }) + .mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found' + }); + + vi.mocked(DotrainOrderGui.getStrategyDetails).mockResolvedValueOnce(mockStrategyDetails); + + render(StrategyPage, { + props: { + strategyName: 'TestStrategy', + dotrain: mockDotrain + } + }); + + await waitFor(() => { + expect(screen.getByText('Test Strategy')).toBeInTheDocument(); + expect(screen.getByTestId('plain-description')).toHaveTextContent( + 'https://example.com/description.md' + ); + }); + }); +}); diff --git a/packages/ui-components/src/__tests__/StrategySection.test.ts b/packages/ui-components/src/__tests__/StrategySection.test.ts deleted file mode 100644 index 9d9a1efdf..000000000 --- a/packages/ui-components/src/__tests__/StrategySection.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/svelte'; -import StrategySection from '../lib/components/deployment/StrategySection.svelte'; -import { DotrainOrderGui } from '@rainlanguage/orderbook/js_api'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; - -// Mock fetch -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -// Mock DotrainOrderGui -vi.mock('@rainlanguage/orderbook/js_api', () => ({ - DotrainOrderGui: { - getStrategyDetails: vi.fn(), - getDeploymentDetails: vi.fn() - } -})); - -describe('StrategySection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders strategy details successfully with rawDotrain', async () => { - const mockDotrain = 'mock dotrain content'; - const mockStrategyDetails = { - name: 'Test Strategy', - description: 'Test Description' - }; - vi.mocked(DotrainOrderGui.getStrategyDetails).mockResolvedValueOnce(mockStrategyDetails); - - render(StrategySection, { - props: { - rawDotrain: mockDotrain - } - }); - - await waitFor(() => { - expect(screen.getByText('Test Strategy')).toBeInTheDocument(); - expect(screen.getByText('Test Description')).toBeInTheDocument(); - }); - }); - - it('renders strategy details successfully from fetch', async () => { - const mockDotrain = 'mock dotrain content'; - const mockStrategyDetails = { - name: 'Test Strategy', - description: 'Test Description' - }; - - // Mock fetch response - mockFetch.mockResolvedValueOnce({ - text: () => Promise.resolve(mockDotrain) - }); - - // Mock DotrainOrderGui methods - vi.mocked(DotrainOrderGui.getStrategyDetails).mockResolvedValueOnce(mockStrategyDetails); - - render(StrategySection, { - props: { - strategyUrl: 'http://example.com/strategy', - strategyName: 'TestStrategy' - } - }); - - await waitFor(() => { - expect(screen.getByText('Test Strategy')).toBeInTheDocument(); - expect(screen.getByText('Test Description')).toBeInTheDocument(); - }); - }); - - it('displays error message when strategy details fail', async () => { - const mockDotrain = 'mock dotrain content'; - const mockError = new Error('Failed to get strategy details'); - - // Mock fetch response - mockFetch.mockResolvedValueOnce({ - text: () => Promise.resolve(mockDotrain) - }); - - // Mock DotrainOrderGui methods - vi.mocked(DotrainOrderGui.getStrategyDetails).mockRejectedValueOnce(mockError); - - render(StrategySection, { - props: { - strategyUrl: 'http://example.com/strategy', - strategyName: 'TestStrategy' - } - }); - - await waitFor(() => { - expect(screen.getByText('Error getting strategy details')).toBeInTheDocument(); - expect(screen.getByText('Failed to get strategy details')).toBeInTheDocument(); - }); - }); - - it('handles fetch failure', async () => { - const mockError = new Error('Failed to fetch'); - - // Mock fetch to reject - mockFetch.mockRejectedValueOnce(mockError); - - render(StrategySection, { - props: { - strategyUrl: 'http://example.com/strategy', - strategyName: 'TestStrategy' - } - }); - - await waitFor(() => { - expect(screen.getByText('Error fetching strategy')).toBeInTheDocument(); - expect(screen.getByText('Failed to fetch')).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/ui-components/src/__tests__/StrategyShortTile.test.ts b/packages/ui-components/src/__tests__/StrategyShortTile.test.ts new file mode 100644 index 000000000..917f168b6 --- /dev/null +++ b/packages/ui-components/src/__tests__/StrategyShortTile.test.ts @@ -0,0 +1,113 @@ +import { vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { describe, it, expect, beforeEach } from 'vitest'; +import StrategyShortTile from '../lib/components/deployment/StrategyShortTile.svelte'; +import { mockPageStore } from '$lib/__mocks__/stores'; + +// Mock using the hoisted store +vi.mock('$app/stores', async () => { + const { mockPageStore } = await import('$lib/__mocks__/stores'); + return { + page: mockPageStore + }; +}); + +describe('StrategyShortTile', () => { + const mockStrategyDetails = { + name: 'Test Strategy', + description: 'A test strategy full description', + short_description: 'A test strategy description' + }; + + beforeEach(() => { + // Reset page URL params before each test + mockPageStore.mockSetSubscribeValue({ + url: new URL('http://localhost:3000'), + params: {}, + route: { id: '' }, + status: 200, + error: null, + data: {}, + form: undefined, + state: { + page: 1, + perPage: 10, + total: 100 + } + }); + }); + + it('renders strategy name and description', () => { + render(StrategyShortTile, { + props: { + strategyDetails: mockStrategyDetails, + registryName: 'test-registry' + } + }); + + expect(screen.getByText('Test Strategy')).toBeInTheDocument(); + expect(screen.getByText('A test strategy description')).toBeInTheDocument(); + }); + + it('generates correct href without registry parameter', () => { + render(StrategyShortTile, { + props: { + strategyDetails: mockStrategyDetails, + registryName: 'test-registry' + } + }); + + const link = screen.getByRole('link'); + expect(link.getAttribute('href')).toBe('/deploy/test-registry'); + }); + + it('generates correct href with registry parameter', () => { + mockPageStore.mockSetSubscribeValue({ + url: new URL('http://localhost:3000?registry=custom-registry'), + params: {}, + route: { id: '' }, + status: 200, + error: null, + data: {}, + form: undefined, + state: { + page: 1, + perPage: 10, + total: 100 + } + }); + + render(StrategyShortTile, { + props: { + strategyDetails: mockStrategyDetails, + registryName: 'test-registry' + } + }); + + const link = screen.getByRole('link'); + expect(link.getAttribute('href')).toBe('/deploy/test-registry?registry=custom-registry'); + }); + + it('applies correct styling classes', () => { + render(StrategyShortTile, { + props: { + strategyDetails: mockStrategyDetails, + registryName: 'test-registry' + } + }); + + const link = screen.getByRole('link'); + expect(link).toHaveClass( + 'flex', + 'flex-col', + 'gap-y-2', + 'rounded-xl', + 'border', + 'border-gray-200', + 'p-4', + 'hover:bg-gray-50', + 'dark:border-gray-800', + 'dark:hover:bg-gray-900' + ); + }); +}); diff --git a/packages/ui-components/src/__tests__/handleShareChoices.test.ts b/packages/ui-components/src/__tests__/handleShareChoices.test.ts new file mode 100644 index 000000000..98b4343ef --- /dev/null +++ b/packages/ui-components/src/__tests__/handleShareChoices.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleShareChoices } from '../lib/services/handleShareChoices'; +import type { DotrainOrderGui } from '@rainlanguage/orderbook/js_api'; + +describe('handleShareChoices', () => { + beforeEach(() => { + // Mock clipboard API + Object.assign(navigator, { + clipboard: { + writeText: vi.fn() + } + }); + + // Mock Svelte's page store + vi.mock('$app/stores', () => ({ + page: { + subscribe: vi.fn((fn) => { + fn({ url: new URL('http://example.com') }); + return () => {}; + }) + } + })); + }); + + it('should share the choices with state', async () => { + const mockGui = { + serializeState: vi.fn().mockReturnValue('mockState123') + }; + + await handleShareChoices(mockGui as unknown as DotrainOrderGui); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'http://example.com/?state=mockState123' + ); + }); + + it('should handle null state', async () => { + const mockGui = { + serializeState: vi.fn().mockReturnValue(null) + }; + + await handleShareChoices(mockGui as unknown as DotrainOrderGui); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('http://example.com/?state='); + }); + + it('should handle undefined gui', async () => { + await handleShareChoices(undefined as unknown as DotrainOrderGui); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('http://example.com/?state='); + }); +}); diff --git a/packages/ui-components/src/lib/__mocks__/stores.ts b/packages/ui-components/src/lib/__mocks__/stores.ts index 933a322f9..f447d0628 100644 --- a/packages/ui-components/src/lib/__mocks__/stores.ts +++ b/packages/ui-components/src/lib/__mocks__/stores.ts @@ -4,6 +4,7 @@ import settingsFixture from '../__fixtures__/settings-12-11-24.json'; import { type Config } from '@wagmi/core'; import { mockWeb3Config } from './mockWeb3Config'; +import type { Page } from '@sveltejs/kit'; const mockSettingsWritable = writable(settingsFixture); const mockActiveSubgraphsWritable = writable>({}); @@ -22,6 +23,7 @@ const mockChainIdWritable = writable(0); const mockConnectedWritable = writable(false); const mockWagmiConfigWritable = writable(mockWeb3Config); const mockShowMyItemsOnlyWritable = writable(false); +const mockPageWritable = writable(); export const mockWalletAddressMatchesOrBlankStore = { subscribe: mockWalletAddressMatchesOrBlankWritable.subscribe, @@ -129,3 +131,9 @@ export const mockShowMyItemsOnlyStore = { set: mockShowMyItemsOnlyWritable.set, mockSetSubscribeValue: (value: boolean): void => mockShowMyItemsOnlyWritable.set(value) }; + +export const mockPageStore = { + subscribe: mockPageWritable.subscribe, + set: mockPageWritable.set, + mockSetSubscribeValue: (value: Page): void => mockPageWritable.set(value) +}; diff --git a/packages/ui-components/src/lib/components/deployment/DeploymentPage.svelte b/packages/ui-components/src/lib/components/deployment/DeploymentPage.svelte deleted file mode 100644 index 4ee92aee3..000000000 --- a/packages/ui-components/src/lib/components/deployment/DeploymentPage.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - - diff --git a/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte b/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte index d9974a560..943b4a59c 100644 --- a/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte +++ b/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte @@ -9,7 +9,6 @@ DotrainOrderGui, type GuiDeposit, type GuiFieldDefinition, - type NameAndDescription, type GuiDeployment, type OrderIO, type ApprovalCalldataResult, @@ -26,6 +25,7 @@ import { onMount } from 'svelte'; import ShareChoicesButton from './ShareChoicesButton.svelte'; import DisclaimerModal from './DisclaimerModal.svelte'; + import { handleShareChoices } from '$lib/services/handleShareChoices'; enum DeploymentStepErrors { NO_GUI = 'Error loading GUI', @@ -43,8 +43,7 @@ } export let dotrain: string; - export let deployment: string; - export let deploymentDetails: NameAndDescription; + export let deployment: GuiDeployment; export let handleDeployModal: (args: { approvals: ApprovalCalldataResult; deploymentCalldata: DepositAndAddOrderCalldataResult; @@ -52,6 +51,7 @@ chainId: number; }) => void; export let handleUpdateGuiState: (gui: DotrainOrderGui) => void; + let selectTokens: SelectTokens | null = null; let allDepositFields: GuiDeposit[] = []; let allTokenOutputs: OrderIO[] = []; @@ -69,7 +69,7 @@ export let stateFromUrl: string | null = null; $: if (deployment) { - handleDeploymentChange(deployment); + handleDeploymentChange(deployment.key); } async function handleDeploymentChange(deployment: string) { @@ -162,9 +162,9 @@ showDisclaimerModal = true; } - async function handleShareChoices() { - // copy the current url to the clipboard - navigator.clipboard.writeText($page.url.toString()); + async function _handleShareChoices() { + if (!gui) return; + await handleShareChoices(gui); } onMount(async () => { @@ -219,13 +219,13 @@ {#if dotrain} {#if gui}
- {#if deploymentDetails} + {#if deployment}

- {deploymentDetails.name} + {deployment.name}

- {deploymentDetails.description} + {deployment.description}

{/if} @@ -256,7 +256,7 @@ {:else} {/if} - +
{#if error} diff --git a/packages/ui-components/src/lib/components/deployment/DeploymentTile.svelte b/packages/ui-components/src/lib/components/deployment/DeploymentTile.svelte index a55243b72..e12661aff 100644 --- a/packages/ui-components/src/lib/components/deployment/DeploymentTile.svelte +++ b/packages/ui-components/src/lib/components/deployment/DeploymentTile.svelte @@ -1,12 +1,24 @@

{name}

diff --git a/packages/ui-components/src/lib/components/deployment/StrategyPage.svelte b/packages/ui-components/src/lib/components/deployment/StrategyPage.svelte new file mode 100644 index 000000000..cc566f1f3 --- /dev/null +++ b/packages/ui-components/src/lib/components/deployment/StrategyPage.svelte @@ -0,0 +1,80 @@ + + +{#if dotrain && strategyDetails} +
+
+
+

+ {strategyDetails.name} +

+ {#if isMarkdownUrl(strategyDetails.description) && markdownContent} +
+ +
+ {:else} +

+ {strategyDetails.description} +

+ {/if} +
+
+

Deployments

+ +
+
+
+{:else if error} +
+

{error}

+

{errorDetails}

+
+{/if} diff --git a/packages/ui-components/src/lib/components/deployment/StrategySection.svelte b/packages/ui-components/src/lib/components/deployment/StrategySection.svelte deleted file mode 100644 index e02237b01..000000000 --- a/packages/ui-components/src/lib/components/deployment/StrategySection.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -{#if dotrain && strategyDetails} -
-
-

- {strategyDetails.name} -

-

- {strategyDetails.description} -

-
- -
-{:else if error} -
-

{error}

-

{errorDetails}

-
-{/if} diff --git a/packages/ui-components/src/lib/components/deployment/StrategyShortTile.svelte b/packages/ui-components/src/lib/components/deployment/StrategyShortTile.svelte new file mode 100644 index 000000000..92a2575f9 --- /dev/null +++ b/packages/ui-components/src/lib/components/deployment/StrategyShortTile.svelte @@ -0,0 +1,24 @@ + + +
+

{strategyDetails?.name}

+

{strategyDetails?.short_description}

+
diff --git a/packages/ui-components/src/lib/components/detail/OrderDetail.svelte b/packages/ui-components/src/lib/components/detail/OrderDetail.svelte index d5241bd0c..2adf3ee81 100644 --- a/packages/ui-components/src/lib/components/detail/OrderDetail.svelte +++ b/packages/ui-components/src/lib/components/detail/OrderDetail.svelte @@ -188,7 +188,7 @@
diff --git a/packages/ui-components/src/lib/index.ts b/packages/ui-components/src/lib/index.ts index 87396d207..38baec9eb 100644 --- a/packages/ui-components/src/lib/index.ts +++ b/packages/ui-components/src/lib/index.ts @@ -1,5 +1,3 @@ -import './app.css'; - // Components export { default as CardProperty } from './components/CardProperty.svelte'; export { default as Hash, HashType } from './components/Hash.svelte'; @@ -59,11 +57,11 @@ export { default as CodeMirrorDotrain } from './components/CodeMirrorDotrain.sve export { default as OrderOrVaultHash } from './components/OrderOrVaultHash.svelte'; export { default as License } from './components/License.svelte'; export { default as ButtonDarkMode } from './components/ButtonDarkMode.svelte'; -export { default as StrategySection } from './components/deployment/StrategySection.svelte'; -export { default as DeploymentPage } from './components/deployment/DeploymentPage.svelte'; +export { default as StrategyPage } from './components/deployment/StrategyPage.svelte'; export { default as InputHex } from './components/input/InputHex.svelte'; export { default as InputTokenAmount } from './components/input/InputTokenAmount.svelte'; export { default as WalletConnect } from './components/wallet/WalletConnect.svelte'; +export { default as StrategyShortTile } from './components/deployment/StrategyShortTile.svelte'; //Types export type { AppStoresInterface } from './types/appStores.ts'; @@ -87,7 +85,6 @@ export { prepareHistoricalOrderChartData } from './services/historicalOrderChart export { bigintToFloat } from './utils/number'; // Constants - export { DEFAULT_PAGE_SIZE, DEFAULT_REFRESH_INTERVAL } from './queries/constants'; export { QKEY_VAULTS, diff --git a/packages/ui-components/src/lib/services/handleShareChoices.ts b/packages/ui-components/src/lib/services/handleShareChoices.ts new file mode 100644 index 000000000..de44105ea --- /dev/null +++ b/packages/ui-components/src/lib/services/handleShareChoices.ts @@ -0,0 +1,12 @@ +import type { DotrainOrderGui } from '@rainlanguage/orderbook/js_api'; +import { page } from '$app/stores'; +import { get } from 'svelte/store'; + +export async function handleShareChoices(gui: DotrainOrderGui) { + // get the current url + const url = get(page).url; + // get the current state + const state = gui?.serializeState(); + url.searchParams.set('state', state || ''); + navigator.clipboard.writeText(url.toString()); +} diff --git a/packages/ui-components/src/lib/services/index.ts b/packages/ui-components/src/lib/services/index.ts new file mode 100644 index 000000000..b1f3a1e4a --- /dev/null +++ b/packages/ui-components/src/lib/services/index.ts @@ -0,0 +1,2 @@ +export { fetchParseRegistry, fetchRegistryDotrains } from './registry'; +export type { RegistryDotrain, RegistryFile } from './registry'; diff --git a/packages/ui-components/src/lib/services/registry.ts b/packages/ui-components/src/lib/services/registry.ts new file mode 100644 index 000000000..09cf1ea66 --- /dev/null +++ b/packages/ui-components/src/lib/services/registry.ts @@ -0,0 +1,173 @@ +export type RegistryFile = { + name: string; + url: string; +}; + +export type RegistryDotrain = { + name: string; + dotrain: string; +}; + +/** + * Fetches and parses a file registry from a given URL. + * The registry is expected to be a text file where each line contains a file name and URL separated by a space. + * + * @param url - The URL of the registry file to fetch + * @returns A Promise that resolves to an array of objects containing file names and their corresponding URLs + * @throws Will throw an error if the fetch fails, if the response is not ok, or if the registry format is invalid + * + * @example + * const files = await fetchParseRegistryFile('https://example.com/registry'); + * // Returns: [{ name: 'file1', url: 'https://example.com/file1.rain' }, ...] + */ + +export const fetchParseRegistry = async (url: string): Promise<{ name: string; url: string }[]> => { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error('Failed to fetch registry.'); + } + const filesList = await response.text(); + const files = filesList + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + const [name, url] = line.split(' '); + return { name, url }; + }); + if (!files) { + throw new Error('Invalid stategy registry.'); + } + return files; + } catch (e) { + throw new Error(e instanceof Error ? e.message : 'Unknown error.'); + } +}; + +export const fetchRegistryDotrains = async (url: string): Promise => { + const files = await fetchParseRegistry(url); + const dotrains = await Promise.all( + files.map(async (file) => { + try { + const response = await fetch(file.url); + if (!response.ok) { + throw new Error(`Failed to fetch dotrain for ${file.name}`); + } + const dotrain = await response.text(); + return { name: file.name, dotrain }; + } catch (e) { + throw new Error( + e instanceof Error + ? `Error fetching dotrain for ${file.name}: ${e.message}` + : `Unknown error fetching dotrain for ${file.name}` + ); + } + }) + ); + return dotrains; +}; + +if (import.meta.vitest) { + const { describe, it, expect, vi } = import.meta.vitest; + + describe('getFileRegistry', () => { + it('should parse registry file content correctly', async () => { + const mockResponse = `file1.js https://example.com/file1.js +file2.js https://example.com/file2.js`; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockResponse) + }); + + const result = await fetchParseRegistry('https://example.com/registry'); + expect(result).toEqual([ + { name: 'file1.js', url: 'https://example.com/file1.js' }, + { name: 'file2.js', url: 'https://example.com/file2.js' } + ]); + }); + + it('should handle failed fetch response', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false + }); + + await expect(fetchParseRegistry('https://example.com/registry')).rejects.toThrow( + 'Failed to fetch registry' + ); + }); + + it('should handle network errors', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + await expect(fetchParseRegistry('https://example.com/registry')).rejects.toThrow( + 'Network error' + ); + }); + }); + + describe('fetchRegistryDotrains', () => { + it('should fetch and parse dotrains correctly', async () => { + const mockRegistry = `file1.rain https://example.com/file1.rain +file2.rain https://example.com/file2.rain`; + + const mockDotrain1 = 'content of file1'; + const mockDotrain2 = 'content of file2'; + + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockRegistry) + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockDotrain1) + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockDotrain2) + }); + + const result = await fetchRegistryDotrains('https://example.com/registry'); + expect(result).toEqual([ + { name: 'file1.rain', dotrain: mockDotrain1 }, + { name: 'file2.rain', dotrain: mockDotrain2 } + ]); + }); + + it('should handle failed dotrain fetch', async () => { + const mockRegistry = `file1.rain https://example.com/file1.rain`; + + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockRegistry) + }) + .mockResolvedValueOnce({ + ok: false + }); + + await expect(fetchRegistryDotrains('https://example.com/registry')).rejects.toThrow( + 'Failed to fetch dotrain for file1.rain' + ); + }); + + it('should handle network errors during dotrain fetch', async () => { + const mockRegistry = `file1.rain https://example.com/file1.rain`; + + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockRegistry) + }) + .mockRejectedValueOnce(new Error('Network error')); + + await expect(fetchRegistryDotrains('https://example.com/registry')).rejects.toThrow( + 'Error fetching dotrain for file1.rain: Network error' + ); + }); + }); +} diff --git a/packages/webapp/env.example b/packages/webapp/env.example index 023f373ba..282fe01e1 100644 --- a/packages/webapp/env.example +++ b/packages/webapp/env.example @@ -1 +1 @@ -PLUBLIC_WALLETCONNECT_PROJECT_ID= \ No newline at end of file +PUBLIC_WALLETCONNECT_PROJECT_ID= \ No newline at end of file diff --git a/packages/webapp/postcss.config.js b/packages/webapp/postcss.config.js index 2e7af2b7f..e99ebc2c0 100644 --- a/packages/webapp/postcss.config.js +++ b/packages/webapp/postcss.config.js @@ -3,4 +3,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -} +} \ No newline at end of file diff --git a/packages/webapp/src/lib/components/Sidebar.svelte b/packages/webapp/src/lib/components/Sidebar.svelte index bc0b32bda..b40e5eb80 100644 --- a/packages/webapp/src/lib/components/Sidebar.svelte +++ b/packages/webapp/src/lib/components/Sidebar.svelte @@ -70,7 +70,7 @@ /> {/if} - + - + @@ -89,7 +89,7 @@ - + @@ -103,10 +103,10 @@ - + - + - + diff --git a/packages/webapp/src/lib/constants.ts b/packages/webapp/src/lib/constants.ts new file mode 100644 index 000000000..87a85bbde --- /dev/null +++ b/packages/webapp/src/lib/constants.ts @@ -0,0 +1,2 @@ +export const REGISTRY_URL = + 'https://raw.githubusercontent.com/rainlanguage/rain.strategies/refs/heads/main/ports/registry'; diff --git a/packages/webapp/src/lib/services/handleUpdateGuiState.ts b/packages/webapp/src/lib/services/handleUpdateGuiState.ts index 2ddd071a9..ef558faf5 100644 --- a/packages/webapp/src/lib/services/handleUpdateGuiState.ts +++ b/packages/webapp/src/lib/services/handleUpdateGuiState.ts @@ -1,3 +1,4 @@ +import { pushState } from '$app/navigation'; import type { DotrainOrderGui } from '@rainlanguage/orderbook/js_api'; import { debounce } from 'lodash'; @@ -8,6 +9,74 @@ export function handleUpdateGuiState(gui: DotrainOrderGui) { const pushGuiStateToUrlHistory = debounce((gui: DotrainOrderGui) => { const serializedState = gui.serializeState(); if (serializedState) { - window.history.pushState({}, '', `?state=${serializedState}`); + pushState(`?state=${serializedState}`, { serializedState }); } }, 1000); + +if (import.meta.vitest) { + const { describe, it, expect, vi } = import.meta.vitest; + + // Mock pushState + vi.mock('$app/navigation', () => ({ + pushState: vi.fn() + })); + + describe('handleUpdateGuiState', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should push state to URL history when serializedState exists', async () => { + const mockSerializedState = 'mockSerializedState123'; + const mockGui = { + serializeState: vi.fn().mockReturnValue(mockSerializedState) + } as unknown as DotrainOrderGui; + + handleUpdateGuiState(mockGui); + + // Fast-forward timers to trigger debounced function + await vi.advanceTimersByTimeAsync(1000); + + expect(pushState).toHaveBeenCalledWith(`?state=${mockSerializedState}`, { + serializedState: mockSerializedState + }); + }); + + it('should not push state when serializedState is falsy', async () => { + const mockGui = { + serializeState: vi.fn().mockReturnValue(null) + } as unknown as DotrainOrderGui; + + handleUpdateGuiState(mockGui); + + await vi.advanceTimersByTimeAsync(1000); + + expect(pushState).not.toHaveBeenCalled(); + }); + + it('should debounce multiple calls', async () => { + const mockSerializedState = 'mockSerializedState123'; + const mockGui = { + serializeState: vi.fn().mockReturnValue(mockSerializedState) + } as unknown as DotrainOrderGui; + + // Call multiple times in quick succession + handleUpdateGuiState(mockGui); + handleUpdateGuiState(mockGui); + handleUpdateGuiState(mockGui); + + await vi.advanceTimersByTimeAsync(1000); + + // Should only be called once due to debouncing + expect(pushState).toHaveBeenCalledTimes(1); + expect(pushState).toHaveBeenCalledWith(`?state=${mockSerializedState}`, { + serializedState: mockSerializedState + }); + }); + }); +} diff --git a/packages/webapp/src/lib/stores/raw-dotrain.ts b/packages/webapp/src/lib/stores/raw-dotrain.ts deleted file mode 100644 index 6b84ac1ed..000000000 --- a/packages/webapp/src/lib/stores/raw-dotrain.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { writable } from 'svelte/store'; - -export const rawDotrain = writable(''); diff --git a/packages/webapp/src/lib/stores/registry.ts b/packages/webapp/src/lib/stores/registry.ts deleted file mode 100644 index f5a51698d..000000000 --- a/packages/webapp/src/lib/stores/registry.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { writable } from 'svelte/store'; - -export const registryUrl = writable( - 'https://raw.githubusercontent.com/rainlanguage/rain.strategies/3b4ef719fc60064d62fff1366afd97d5715ddd4a/ports/registry' -); diff --git a/packages/webapp/src/routes/deploy/+layout.ts b/packages/webapp/src/routes/deploy/+layout.ts index ab526df3e..4f2fd310b 100644 --- a/packages/webapp/src/routes/deploy/+layout.ts +++ b/packages/webapp/src/routes/deploy/+layout.ts @@ -1,11 +1,19 @@ -import { registryUrl } from '$lib/stores/registry'; +import { REGISTRY_URL } from '$lib/constants'; +import { fetchRegistryDotrains } from '@rainlanguage/ui-components/services'; import type { LayoutLoad } from './$types'; +import { DotrainOrderGui } from '@rainlanguage/orderbook/js_api'; export const load: LayoutLoad = async ({ url }) => { // get the registry url from the url params const registry = url.searchParams.get('registry'); - if (registry) { - registryUrl.set(registry); - } - return { registry }; + + const registryDotrains = await fetchRegistryDotrains(registry || REGISTRY_URL); + const strategyDetails = await Promise.all( + registryDotrains.map(async (registryDotrain) => { + const details = await DotrainOrderGui.getStrategyDetails(registryDotrain.dotrain); + return { ...registryDotrain, details }; + }) + ); + + return { registry: registry || REGISTRY_URL, registryDotrains, strategyDetails }; }; diff --git a/packages/webapp/src/routes/deploy/+page.svelte b/packages/webapp/src/routes/deploy/+page.svelte index 1259466d2..9ed35f7a3 100644 --- a/packages/webapp/src/routes/deploy/+page.svelte +++ b/packages/webapp/src/routes/deploy/+page.svelte @@ -1,46 +1,18 @@ @@ -52,79 +24,42 @@ > -
-
- {#if advancedMode} -
-
- - -
-
-