Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(explorer): cluster testing #947

Merged
merged 1 commit into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/explorer-e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { defineConfig, devices } from '@playwright/test'
import { nxE2EPreset } from '@nx/playwright/preset'

import { workspaceRoot } from '@nx/devkit'

// For CI, you may want to set BASE_URL to the deployed application.
Expand All @@ -25,8 +24,8 @@ export default defineConfig({
trace: 'on-first-retry',
video: 'on-first-retry',
},
// Timeout per test.
timeout: 60_000,
// Timeout per test. The cluster takes up to 30 seconds to start and form contracts.
timeout: 180_000,
expect: {
// Raise the timeout because it is running against next dev mode
// which requires compilation the first to a page is visited.
Expand Down
90 changes: 90 additions & 0 deletions apps/explorer-e2e/src/fixtures/cluster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Explored } from '@siafoundation/explored-js'
import { Hostd } from '@siafoundation/hostd-js'
import { Bus } from '@siafoundation/renterd-js'
import { Walletd } from '@siafoundation/walletd-js'
import { clusterd, setupCluster } from '@siafoundation/clusterd'
import { BrowserContext } from 'playwright'

export type Cluster = Awaited<ReturnType<typeof startCluster>>

export async function startCluster({
renterdCount = 1,
walletdCount = 1,
hostdCount = 3,
context,
}: {
renterdCount?: number
walletdCount?: number
hostdCount?: number
context: BrowserContext
}) {
await setupCluster({
exploredCount: 1,
renterdCount,
walletdCount,
hostdCount,
})
const explored = clusterd.nodes.find((n) => n.type === 'explored')
const renterds = clusterd.nodes.filter((n) => n.type === 'renterd')
const hostds = clusterd.nodes.filter((n) => n.type === 'hostd')
const walletds = clusterd.nodes.filter((n) => n.type === 'walletd')
if (
explored === undefined ||
renterds.length !== renterdCount ||
hostds.length !== hostdCount ||
walletds.length !== walletdCount
) {
throw new Error('Failed to start cluster')
}
const daemons = {
explored: {
node: explored,
api: Explored({
api: `${explored.apiAddress}/api`,
password: explored.password,
}),
},
renterds: renterds.map((r) => ({
node: r,
api: Bus({
api: `${r.apiAddress}/api`,
password: r.password,
}),
})),
hostds: hostds.map((h) => ({
node: h,
api: Hostd({
api: `${h.apiAddress}/api`,
password: h.password,
}),
})),
walletds: walletds.map((w) => ({
node: w,
api: Walletd({
api: `${w.apiAddress}/api`,
password: w.password,
}),
})),
}
console.log(`
clusterd: http://localhost:${clusterd.managementPort}
explored: ${daemons.explored.node.apiAddress}
renterds: ${daemons.renterds.map((r) => r.node.apiAddress)}
hostds: ${daemons.hostds.map((h) => h.node.apiAddress)}
walletds: ${daemons.walletds.map((w) => w.node.apiAddress)}
`)
// Set the explorerd address cookie so that the explorer app overrides the
// normal zen address with the testnet cluster address.
await context.addCookies([
{
// This should match `exploredCustomApiCookieName` in apps/explorer/config/explored.ts
name: 'exploredAddress',
value: daemons.explored.node.apiAddress,
url: 'http://localhost:3005',
},
])
return {
clusterd,
daemons,
}
}
9 changes: 0 additions & 9 deletions apps/explorer-e2e/src/fixtures/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,6 @@ export const TEST_TX_1 = {
},
}

export const TEST_ADDRESS_1 = {
id: '68bf48e81536f2221f3809aa9d1c89c1c869a17c6f186a088e49fd2605e4bfaaa24f26e4c42c',
display: {
title: 'Address 68bf48e81536f22...',
transactionNumber: '500 events',
transactionID: '95a2b31155be...',
},
}

export const TEST_CONTRACT_1 = {
id: '25c94822bf7bd86a92d28a148d9d30151949f3599bf93af0df7b4f1e1b3c990d',
renewedFromTitle: 'Contract 494d147a8028217...',
Expand Down
82 changes: 82 additions & 0 deletions apps/explorer-e2e/src/fixtures/walletd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Walletd } from '@siafoundation/walletd-js'
import { blake2bHex } from 'blakejs'
import { WalletAddressMetadata } from '@siafoundation/walletd-types'
import { to } from '@siafoundation/request'
import { mine } from '@siafoundation/clusterd'
import { humanSiacoin } from '@siafoundation/units'
import { Cluster } from './cluster'

export async function addWalletToWalletd(walletd: ReturnType<typeof Walletd>) {
// For some reason when this code runs on GitHub Actions it throws an ESM
// import error for the SDK. Running locally it works fine.
// Error: require() of ES Module sdk/index.esm.js from /fixtures/walletd.ts not supported.
// This dynamic import is a workaround.
const { initSDK, getSDK } = await import('@siafoundation/sdk')
await initSDK()
const sdk = getSDK()
const { phrase: mnemonic } = sdk.wallet.generateSeedPhrase()
const mnemonicHash = blake2bHex(mnemonic)
const [wallet, walletError] = await to(
walletd.walletAdd({
data: {
name: 'test',
description: 'test',
metadata: {
type: 'seed',
mnemonicHash,
},
},
})
)
if (!wallet || walletError) {
throw new Error(`Failed to add wallet: ${walletError}`)
}
const kp = sdk.wallet.keyPairFromSeedPhrase(mnemonic, 0)
const suh = sdk.wallet.standardUnlockHash(kp.publicKey)
const uc = sdk.wallet.standardUnlockConditions(kp.publicKey)
const metadata: WalletAddressMetadata = {
index: 0,
}
const [, addressError] = await to(
walletd.walletAddressAdd({
params: {
id: wallet.id,
},
data: {
address: suh.address,
description: '',
spendPolicy: {
type: 'uc',
policy: uc.unlockConditions,
},
metadata,
},
})
)
if (addressError) {
throw new Error(`Failed to add address: ${addressError}`)
}
return { wallet, address: suh.address }
}

// renterd cluster nodes have siacoins we can use to fund other wallets.
export async function sendSiacoinFromRenterd(
renterd: Cluster['daemons']['renterds'][number],
address: string,
amount: string
) {
console.log(`Sending ${humanSiacoin(amount)} from renterd to:`, address)
try {
// Send some funds to the wallet.
await renterd.api.walletSend({
data: {
address,
amount,
subtractMinerFee: false,
},
})
await mine(1)
} catch (e) {
console.log('error sending siacoin', e)
}
}
103 changes: 76 additions & 27 deletions apps/explorer-e2e/src/specs/address.spec.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,102 @@
import { test, expect } from '@playwright/test'
import { ExplorerApp } from '../fixtures/ExplorerApp'
import { TEST_ADDRESS_1 } from '../fixtures/constants'
import { keys } from '../utils'
import { startCluster } from '../fixtures/cluster'
import {
mine,
renterdWaitForContracts,
teardownCluster,
} from '@siafoundation/clusterd'
import { addWalletToWalletd, sendSiacoinFromRenterd } from '../fixtures/walletd'
import { Cluster } from '../fixtures/cluster'
import { toHastings } from '@siafoundation/units'

let explorerApp: ExplorerApp
let cluster: Cluster

test.beforeEach(async ({ page }) => {
test.beforeEach(async ({ page, context }) => {
// Start the cluster which includes an explored node, a renterd node, a
// walletd node, and 3 hostd nodes.
cluster = await startCluster({ context })
// The renterd node will automatically form contracts with the hostd nodes.
// Wait for the contracts to form so that all related activity is on the
// blockchain and visible to explored.
await renterdWaitForContracts({
renterdNode: cluster.daemons.renterds[0].node,
hostdCount: cluster.daemons.hostds.length,
})
explorerApp = new ExplorerApp(page)
})

test.afterEach(async () => {
await teardownCluster()
})

test('address can be searched by id', async ({ page }) => {
const wallet = await cluster.daemons.renterds[0].api.wallet()
await explorerApp.goTo('/')
await explorerApp.navigateBySearchBar(TEST_ADDRESS_1.id)

await expect(page.getByText(TEST_ADDRESS_1.display.title)).toBeVisible()
await explorerApp.navigateBySearchBar(wallet.data.address)
await expect(
page
.getByTestId('entity-heading')
.getByText(`Address ${wallet.data.address.slice(0, 5)}`)
).toBeVisible()
})

test('address can be directly navigated to by id', async ({ page }) => {
await explorerApp.goTo('/address/' + TEST_ADDRESS_1.id)

await expect(page.getByText(TEST_ADDRESS_1.display.title)).toBeVisible()
const wallet = await cluster.daemons.renterds[0].api.wallet()
await explorerApp.goTo('/address/' + wallet.data.address)
await expect(
page
.getByTestId('entity-heading')
.getByText(`Address ${wallet.data.address.slice(0, 5)}`)
).toBeVisible()
})

test('address displays the intended data', async ({ page }) => {
const displayKeys = keys(TEST_ADDRESS_1.display)

await explorerApp.goTo('/address/' + TEST_ADDRESS_1.id)

for (const key of displayKeys) {
const currentProperty = TEST_ADDRESS_1.display[key]
await expect(page.getByText(currentProperty)).toBeVisible()
}
})

test('address can navigate to the unspent outputs list', async ({ page }) => {
await explorerApp.goTo('/address/' + TEST_ADDRESS_1.id)
const walletd = cluster.daemons.walletds[0]
const { wallet, address } = await addWalletToWalletd(walletd.api)
await sendSiacoinFromRenterd(
cluster.daemons.renterds[0],
address,
toHastings(1_000_000).toString()
)
await mine(10)
const events = await walletd.api.walletEvents({
params: {
id: wallet.id,
limit: 1_000,
offset: 0,
},
})
const outputs = await walletd.api.walletOutputsSiacoin({
params: {
id: wallet.id,
},
})
await explorerApp.goTo('/address/' + address)
await expect(page.getByText(`Address ${address.slice(0, 5)}`)).toBeVisible()
await expect(page.getByText(events.data[0].id.slice(0, 5))).toBeVisible()
await expect(page.getByText(`${events.data.length} events`)).toBeVisible()
await page.getByRole('tab').getByText('Unspent outputs').click()

await expect(page.getByText('Siacoin output').first()).toBeVisible()
await expect(
page.getByText(outputs.data.outputs[0].id.slice(0, 5))
).toBeVisible()
})

test('address can navigate through to a transaction', async ({ page }) => {
await explorerApp.goTo('/address/' + TEST_ADDRESS_1.id)
const wallet = await cluster.daemons.renterds[0].api.wallet()
const events = await cluster.daemons.renterds[0].api.walletEvents({
params: {
limit: 1,
offset: 0,
},
})
await explorerApp.goTo('/address/' + wallet.data.address)

await page
.locator(
'a[data-testid="entity-link"][href*="b24a7d623b82206cd3363db0c0d41446f106a59227d86ef081d601315dbd8cca"]'
)
.locator(`a[data-testid="entity-link"][href*="${events.data[0].id}"]`)
.click()

await expect(page.getByText('Transaction b24a7d623b82206...')).toBeVisible()
await expect(page.getByText(events.data[0].id.slice(0, 5))).toBeVisible()
})
4 changes: 2 additions & 2 deletions apps/explorer/app/address/[id]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { humanSiacoin, humanSiafund } from '@siafoundation/units'
import { getOGImage } from '../../../components/OGImageEntity'
import { truncate } from '@siafoundation/design-system'
import { to } from '@siafoundation/request'
import { explored } from '../../../config/explored'
import { getExplored } from '../../../lib/explored'

export const revalidate = 0

Expand All @@ -18,7 +18,7 @@ export default async function Image({ params }) {
const address = params?.id as string

const [[balance, balanceError]] = await Promise.all([
to(explored.addressBalance({ params: { address } })),
to(getExplored().addressBalance({ params: { address } })),
])

if (balanceError) throw balanceError
Expand Down
8 changes: 4 additions & 4 deletions apps/explorer/app/address/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { buildMetadata } from '../../../lib/utils'
import { notFound } from 'next/navigation'
import { truncate } from '@siafoundation/design-system'
import { to } from '@siafoundation/request'
import { explored } from '../../../config/explored'
import { getExplored } from '../../../lib/explored'

export function generateMetadata({ params }): Metadata {
const id = decodeURIComponent((params?.id as string) || '')
Expand All @@ -29,9 +29,9 @@ export default async function Page({ params }) {
[events, eventsError],
[unspentOutputs, unspentOutputsError],
] = await Promise.all([
to(explored.addressBalance({ params: { address } })),
to(explored.addressEvents({ params: { address, limit: 500 } })),
to(explored.addressSiacoinUTXOs({ params: { address } })),
to(getExplored().addressBalance({ params: { address } })),
to(getExplored().addressEvents({ params: { address, limit: 500 } })),
to(getExplored().addressSiacoinUTXOs({ params: { address } })),
])

if (balanceError) throw balanceError
Expand Down
Loading
Loading