Skip to content
Open
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
16 changes: 9 additions & 7 deletions .storybook/__mocks__/MockClusterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ const defaultState: ClusterState = {
status: ClusterStatus.Connected,
};

export const defaultEpochInfo: ClusterInfo['epochInfo'] = {
absoluteSlot: 312_456_789n,
blockHeight: 295_456_321n,
epoch: 520n,
slotIndex: 156_789n,
slotsInEpoch: 432_000n,
};

const defaultClusterInfo: ClusterInfo = {
epochInfo: {
absoluteSlot: 312_456_789n,
blockHeight: 295_456_321n,
epoch: 520n,
slotIndex: 156_789n,
slotsInEpoch: 432_000n,
},
epochInfo: defaultEpochInfo,
epochSchedule: {
firstNormalEpoch: 14n,
firstNormalSlot: 524_256n,
Expand Down
10 changes: 3 additions & 7 deletions .storybook/__mocks__/MockStatsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,12 @@ import type { DashboardInfo } from '@providers/stats/solanaDashboardInfo';
import type { PerformanceInfo } from '@providers/stats/solanaPerformanceInfo';
import type { ReactNode } from 'react';

import { defaultEpochInfo } from './MockClusterProvider';

const defaultDashboard: DashboardInfo = {
msPerSlot_1h: 420,
msPerSlot_1min: 400,
epochInfo: {
absoluteSlot: 312_456_789n,
blockHeight: 295_456_321n,
epoch: 520n,
slotIndex: 156_789n,
slotsInEpoch: 432_000n,
},
epochInfo: defaultEpochInfo,
status: ClusterStatsStatus.Ready,
};

Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ Once you have these versions installed, you can continue with the following step
You will also see any lint errors in the console.

- `pnpm test` \
Runs the Vitest suite once. Use `pnpm test:watch` for the interactive watch mode.
Runs the Vitest suite once. Use `pnpm test:watch` for the interactive watch mode. \
A `.tsx` spec runs in jsdom and a `.ts` spec runs in node. Put `// @vitest-environment jsdom` on the first line of a `.ts` spec that renders or reads the DOM.

### Troubleshooting

Expand Down
5 changes: 0 additions & 5 deletions app/__fixtures__/gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@ export const gen = {
return BASE58_DECODER.decode(bytes);
},
bigint: (max = 1_000_000n) => BigInt(Math.floor(Math.random() * Number(max))),
blockHeight: () => gen.bigint(250_000_000n),
/** Deterministic blockhash (same seed → same value) so story fixtures stay pixel-stable. */
blockhash: (seed = 0) => {
const bytes = new Uint8Array(32);
for (let i = 0; i < bytes.length; i++) bytes[i] = (seed * 7 + i * 13) & 0xff;
return BASE58_DECODER.decode(bytes);
},
epoch: () => gen.bigint(1_000n),
/** Same as `address` but returns a `PublicKey` so callers needn't wrap it. */
publicKey: (seed?: number) => new PublicKey(gen.address(seed)),
/** Bare resolved security.txt for hook mocks; pass `fields` (e.g. name, version) for the case at hand. */
Expand Down Expand Up @@ -86,8 +84,5 @@ export const DEFAULT_SLOT = Number(gen.slot(0));
/** Stable single-placeholder signature (base58, 64 bytes). */
export const DEFAULT_SIGNATURE = gen.signature(0);

/** Stable single-placeholder unix timestamp (seconds since epoch). */
export const DEFAULT_TIMESTAMP = gen.timestamp(0);

/** Stable rpc url placeholder. */
export const DEFAULT_RPC_URL = 'https://rpc.example.com';
70 changes: 70 additions & 0 deletions app/__tests__/card-harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { TxInstructionSurface } from '@entities/instruction-card';
import { type InstructionParserDispatcher, InstructionParserProvider } from '@entities/instruction-parser';
import { render, screen, within } from '@testing-library/react';
import type { ReactElement, ReactNode } from 'react';

import { AccountsProvider } from '@/app/providers/accounts';
import { ClusterProvider } from '@/app/providers/cluster';
import { ScrollAnchorProvider } from '@/app/providers/scroll-anchor';
import { TransactionsProvider } from '@/app/providers/transactions';

type ProviderOptions = {
transactions?: boolean;
dispatcher?: InstructionParserDispatcher;
txSurface?: boolean;
};

/** Renders `ui` under the app providers; `rerender` keeps them. */
export function renderWithProviders(ui: ReactElement, options: ProviderOptions = {}) {
return render(ui, { wrapper: providers(options) });
}

/** Renders an instruction card on the transaction-page surface. */
export function renderTxCard(card: ReactElement) {
return renderWithProviders(card, { txSurface: true });
}

function providers({ transactions = true, dispatcher, txSurface = false }: ProviderOptions) {
return function Providers({ children }: { children: ReactNode }) {
let tree = children;
if (txSurface) tree = <TxInstructionSurface result={{ err: null }}>{tree}</TxInstructionSurface>;
if (dispatcher) tree = <InstructionParserProvider dispatcher={dispatcher}>{tree}</InstructionParserProvider>;
tree = <AccountsProvider>{tree}</AccountsProvider>;
if (transactions) tree = <TransactionsProvider>{tree}</TransactionsProvider>;
return (
<ScrollAnchorProvider>
<ClusterProvider>{tree}</ClusterProvider>
</ScrollAnchorProvider>
);
};
}

export type CardRow = [label: string, value: string];

/** The first table's own rows in render order; rows of a nested table are left out. */
export function readCardRows(): CardRow[] {
const card = screen.getAllByRole('table')[0];
return within(card)
.getAllByRole('row')
.filter(row => row.closest('table') === card)
.map(row => {
const cells = within(row).getAllByRole('cell');
return [cells[0].textContent ?? '', readAddress(cells[1]) ?? cells[1]?.textContent ?? ''];
});
}

/** The cell's addresses, comma-joined. The text shows a truncated address; `data-address` holds all of it. */
export function readAddress(cell: HTMLElement | undefined): string | undefined {
// eslint-disable-next-line testing-library/no-node-access -- an address has no role to query by
const addresses = [...(cell?.querySelectorAll('[data-address]') ?? [])].map(el => el.getAttribute('data-address'));
return addresses.length > 0 ? addresses.join(',') : undefined;
}

export function findCell(label: string): HTMLElement | undefined {
const row = screen.getAllByRole('row').find(r => within(r).getAllByRole('cell')[0]?.textContent === label);
return row && within(row).getAllByRole('cell')[1];
}

export function readCell(label: string): string {
return findCell(label)?.textContent ?? '';
}
48 changes: 29 additions & 19 deletions app/__tests__/mock-rpc.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { vi } from 'vitest';
// Every spec file loads this module through the setup file. Importing `gen` or `@solana/kit` here
// would load kit and web3.js into every spec file.
import { type Mock, vi } from 'vitest';

import { gen } from '@/app/__fixtures__/gen';
import { GENESIS_HASHES } from '@/app/entities/chain-id';
import type { Dispatch, Update } from '@/app/providers/cache';
import type { ClusterInfo } from '@/app/providers/cluster';
import type { EpochSchedule } from '@/app/utils/epoch-schedule';

interface EpochInfo {
absoluteSlot: bigint;
Expand All @@ -15,28 +16,17 @@ interface EpochInfo {

export const mockEpochInfo = (overrides?: Partial<EpochInfo>): EpochInfo => {
const slotsInEpoch = 432_000n;
const absoluteSlot = gen.slot();
const absoluteSlot = 250_000_000n;
return {
absoluteSlot,
blockHeight: gen.blockHeight(),
blockHeight: 230_000_000n,
epoch: absoluteSlot / slotsInEpoch,
slotIndex: absoluteSlot % slotsInEpoch,
slotsInEpoch,
...overrides,
};
};

export const mockEpochSchedule = (overrides?: Partial<EpochSchedule>): EpochSchedule => ({
firstNormalEpoch: gen.epoch(),
firstNormalSlot: gen.slot(),
slotsPerEpoch: 432_000n,
...overrides,
});

export const mockGenesisHash = (hash?: string): string => hash ?? GENESIS_HASHES.MAINNET;

export const mockFirstAvailableBlock = (block?: bigint): bigint => block ?? gen.slot();

/** Creates a mock RPC object matching the shape returned by createSolanaRpc() */
export const mockSolanaRpc = (
overrides?: Partial<ClusterInfo> & { firstAvailableBlock?: bigint; genesisHash?: string },
Expand All @@ -50,16 +40,19 @@ export const mockSolanaRpc = (
}),
getEpochSchedule: () => ({
send: vi.fn().mockResolvedValue({
...mockEpochSchedule(overrides?.epochSchedule),
firstNormalEpoch: 0n,
firstNormalSlot: 0n,
slotsPerEpoch: 432_000n,
...overrides?.epochSchedule,
leaderScheduleSlotOffset: 0n,
warmup: false,
}),
}),
getFirstAvailableBlock: () => ({
send: vi.fn().mockResolvedValue(mockFirstAvailableBlock(overrides?.firstAvailableBlock)),
send: vi.fn().mockResolvedValue(overrides?.firstAvailableBlock ?? 0n),
}),
getGenesisHash: () => ({
send: vi.fn().mockResolvedValue(mockGenesisHash(overrides?.genesisHash)),
send: vi.fn().mockResolvedValue(overrides?.genesisHash ?? GENESIS_HASHES.MAINNET),
}),
getMultipleAccounts: (addresses: readonly unknown[]) => ({
send: vi.fn().mockResolvedValue({
Expand All @@ -68,3 +61,20 @@ export const mockSolanaRpc = (
}),
}),
});

/** Wraps each mock as a kit RPC method, so `rpc.method(...args).send()` returns `method(...args)`. */
export function rpcStub(methods: Record<string, Mock>) {
return Object.fromEntries(
Object.entries(methods).map(([name, method]) => [
name,
(...args: unknown[]) => ({ send: () => method(...args) }),
]),
);
}

/** Returns the update a mocked cache dispatch received last. */
export function lastDispatch<T>(dispatch: Mock<Dispatch<T>>): Update<T> {
const action = dispatch.mock.lastCall?.[0];
if (!action || !('status' in action)) throw new Error('the last dispatch was not an update');
return action;
}
3 changes: 0 additions & 3 deletions app/__tests__/mock-stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
* Contains serialized VersionedMessages which can be viewed at the inspector by related URLs
*/

// stub a test to not allow passing without tests
test('should stub', () => expect(true).toBeTruthy());

// Message with computeBudget instructions
// http://localhost:3000/tx/inspector?message=gAIABgwLFFx82WdzBluGohZnk8TmXq%252F0pUUnxhpjxSNMraGelLVLQTka3xzxe6hY3Q5dyh8MwvAULx%252BJiPZjdz6%252FaB0L9Lqt3uRNrVn1pxcSD2DLOW0u7YcBbR9bqZdxSdFeT%252FwUlFkNon7Uoh%252BC72aIW5YavKARbD1UjdRpAiXrDl0ryvzVcRVtGJcJNXfKuwGjzfmQhFehPY9M8ebYtDAMvSK8srY9g9IH4ppqsXreT6D%252BTmlKdu2JXmEcnGtzHE8Q9OIDBkZv5SEXMv%252Fsrbpyw5vnvIzlu8X3EmssQ5s6QAAAAIyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqQR51VvyMcBu7nTFbs5oFQf9sbLeo%252FSOUQKxzaJWvBOPtD%252F6J%252FXX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5eicyfP%252FJFK6VWlYc2EGEyE78uvE0UgAvaW5%252BvYLw8BggAYGAAkDZK1tAAAAAAAGAAUCEmACAAcGAAIBEggJAQEHBgADARMICQEBCi0JAQQCChIFCwoUCQ8VDxARDw8PDw8PDw8EAwEUCQwVDA0ODAwMDAwMDAwDAgEn5RfLl3rjrSoCAAAAB2QAAQdkAQIAypo7AAAAAKeXKAIAAAAAlgBVCQMDAQEBCQLiCK3IP8r%252FwRrlsnWHJ6M%252FxtgYtM4dwqjJg6tYf5r2EgMfIKEE2QYREMI4UPEU19u%252Bw1q7jjS3B54B4IVrQrOf4fF1hSod0iZUA1eTUwA%253D
export const computeBudgetMsg =
Expand Down
54 changes: 12 additions & 42 deletions app/__tests__/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,25 @@
import {
Message,
MessageArgs,
MessageCompiledInstruction,
MessageV0,
MessageV0Args,
PublicKey,
TransactionMessage,
VersionedMessage,
} from '@solana/web3.js';
import { vi } from 'vitest';
import { expect } from 'vitest';

// stub a test to not allow passing without tests
test('should stub', () => expect(true).toBeTruthy());
import { resolveAddressLookupTables } from './mock-resolvers';

vi.mock('next/navigation', () => {
const actual = vi.importActual('next/navigation');
const cluster = 'mainnet-beta';
const customUrl = undefined;

return {
...actual,
usePathname: vi.fn(),
useRouter: vi.fn(() => ({
push: vi.fn(),
})),
useSearchParams: vi.fn(() => ({
get: (param: string) => {
if (param === 'cluster') return cluster;
return null;
},
has: (param: string) => {
if (param === 'customUrl' && customUrl) return true;
return false;
},
toString: () => {
let clusterString;
if (cluster !== 'mainnet-beta') clusterString = `cluster=${cluster}`;
if (customUrl) {
return `customUrl=${customUrl}${clusterString ? `&${clusterString}` : ''}`;
}
return clusterString ?? '';
},
})),
};
});
/** Lookup tables resolve from stored copies, so decompiling a stub needs no network. */
export function decompileStubInstruction(stub: string, index: number, { programId }: { programId: string }) {
const message = 'staticAccountKeys' in JSON.parse(stub) ? deserializeMessageV0(stub) : deserializeMessage(stub);
const instruction = TransactionMessage.decompile(message, {
addressLookupTableAccounts: resolveAddressLookupTables(message.addressTableLookups),
}).instructions[index];
expect(instruction.programId.toBase58()).toBe(programId);
return { instruction, message };
}

export function deserializeMessage(message: string): VersionedMessage {
const m = JSON.parse(message) as MessageArgs;
Expand Down Expand Up @@ -92,10 +69,3 @@ export function deserializeMessageV0(message: string): VersionedMessage {

return vm;
}

export function deserializeInstruction(instruction: string): MessageCompiledInstruction {
const data = JSON.parse(instruction);
data.data = Uint8Array.from(data.data.data);

return data;
}
9 changes: 9 additions & 0 deletions app/__tests__/next-navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { vi } from 'vitest';

// Stable identities, as the real hooks give: a new router per render re-runs every effect that depends on it.
const router = { push: vi.fn(), replace: vi.fn() };
const searchParams = new URLSearchParams();

export const usePathname = vi.fn(() => '/');
export const useRouter = vi.fn(() => router);
export const useSearchParams = vi.fn(() => searchParams);
30 changes: 30 additions & 0 deletions app/__tests__/rpc-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { afterEach, beforeEach, vi } from 'vitest';

/**
* Serves JSON-RPC responses to the real kit client through a stubbed global `fetch`.
* The calling spec must also undo the global `@solana/kit` mock, or kit never reaches `fetch`.
*/
export function stubRpcFetch() {
const fetchMock = vi.fn();

beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
fetchMock.mockReset();
});

function respondWith(result: unknown) {
const body = JSON.stringify({ id: 1, jsonrpc: '2.0', result });
// kit reads the body as text so it can upcast integers to bigints as it parses.
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, text: async () => body });
}

function requestBody() {
return JSON.parse(fetchMock.mock.calls[0][1].body);
}

return { fetchMock, requestBody, respondWith };
}
Loading
Loading