Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ The diagnostics page safely aggregates:
## Sharing a Report
When opening a GitHub Issue or requesting support in Discord, click **Copy Report**. This produces a sanitized JSON blob of your current application state.

The report is built by `buildDiagnosticsReport()` in `src/lib/diagnostics/buildReport.ts`,
a pure function that redacts all sensitive values (RPC URL paths, contract IDs, wallet
addresses) via the shared `redact` helpers. The report and its status cards are
unit-tested with healthy and failing fixtures in
`src/lib/diagnostics/buildReport.test.ts`.

**Example Redacted Report:**
```json
{
Expand Down
97 changes: 20 additions & 77 deletions src/features/diagnostics/components/DiagnosticsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import StatusCard from './StatusCard';
import { redactUrl, redactContractId } from '@/lib/diagnostics/redact';
import { buildDiagnosticsReport } from '@/lib/diagnostics/buildReport';
import { useWallet } from '@/hooks/useWallet';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
import { isProviderMocked } from '@/lib/sdk';
Expand All @@ -14,50 +14,21 @@ export default function DiagnosticsPanel() {
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || '';
const contractId = process.env.NEXT_PUBLIC_AEGIS_CONTRACT_ID || '';

const redactedRpc = redactUrl(rpcUrl);
const redactedContract = redactContractId(contractId);

const mockActive = isProviderMocked();

// Config validation (Issue #8). Only the issue *count* and *field names* are
// surfaced here — never raw env values — so this stays safe to include in a
// diagnostics report someone might paste into a support channel.
const configResult = validateDashboardConfig();
const configErrorCount = configResult.issues.filter((i) => i.level === 'error').length;
const configWarningCount = configResult.issues.filter((i) => i.level === 'warning').length;

let configStatusValue: string;
let configStatus: 'ok' | 'warning' | 'error' | 'unknown';
if (mockActive) {
configStatusValue = '[MOCK] Skipped — mock provider active';
configStatus = 'warning';
} else if (configErrorCount > 0) {
configStatusValue = `${configErrorCount} error(s), ${configWarningCount} warning(s)`;
configStatus = 'error';
} else if (configWarningCount > 0) {
configStatusValue = `Valid — ${configWarningCount} warning(s)`;
configStatus = 'warning';
} else {
configStatusValue = 'Valid';
configStatus = 'ok';
}

const reportData = {
timestamp: new Date().toISOString(),
sdkVersion: mockActive ? '[MOCK] v0.0.0-local' : 'Mocked v0.0.0',
rpc: mockActive ? '[MOCK] Not connected — mock provider active' : redactedRpc,
contract: mockActive ? '[MOCK] Not deployed — mock provider active' : redactedContract,
wallet: address ? redactContractId(address) : 'Not connected',
network: mockActive ? 'LOCAL_MOCK' : network || 'Not connected',
configValidation: {
valid: mockActive ? true : configResult.valid,
errorCount: mockActive ? 0 : configErrorCount,
warningCount: mockActive ? 0 : configWarningCount,
fields: mockActive ? [] : configResult.issues.map((i) => `${i.field} (${i.level})`),
const { report: reportData, cards } = buildDiagnosticsReport(
{
walletAddress: address,
walletNetwork: typeof network === 'string' ? network : null,
flags,
mockActive,
rpcUrl,
contractId,
sdkVersion: 'Mocked v0.0.0',
},
flags: flags,
provider: mockActive ? 'MockAegisProvider' : 'LiveAegisProvider',
};
configResult,
);

const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(reportData, null, 2));
Expand Down Expand Up @@ -91,42 +62,14 @@ export default function DiagnosticsPanel() {
)}

<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Provider row — always shown so the active provider is visible */}
<StatusCard
title="Active Provider"
value={reportData.provider}
status={mockActive ? 'warning' : 'ok'}
/>
<StatusCard
title="Config Validation"
value={configStatusValue}
status={configStatus}
/>
<StatusCard
title="RPC URL"
value={mockActive ? '[MOCK] Not connected' : redactedRpc}
status={mockActive ? 'warning' : rpcUrl ? 'ok' : 'error'}
/>
<StatusCard
title="Contract ID"
value={mockActive ? '[MOCK] Not deployed' : redactedContract}
status={mockActive ? 'warning' : contractId ? 'ok' : 'error'}
/>
<StatusCard
title="SDK Version"
value={reportData.sdkVersion}
status="warning"
/>
<StatusCard
title="Wallet Address"
value={address ? redactContractId(address) : 'Not connected'}
status={address ? 'ok' : 'unknown'}
/>
<StatusCard
title="Wallet Network"
value={mockActive ? 'LOCAL_MOCK' : network || 'Not connected'}
status={mockActive ? 'warning' : network ? 'ok' : 'unknown'}
/>
{cards.map((card) => (
<StatusCard
key={card.title}
title={card.title}
value={card.value}
status={card.status}
/>
))}
</div>

<div className="mt-6">
Expand Down
97 changes: 97 additions & 0 deletions src/lib/diagnostics/buildReport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import { buildDiagnosticsReport } from './buildReport';
import type { ConfigValidationResult } from '@/config/validate';

const validConfig: ConfigValidationResult = { valid: true, issues: [] };

const errorConfig: ConfigValidationResult = {
valid: false,
issues: [
{ field: 'NEXT_PUBLIC_RPC_URL', level: 'error', message: 'Missing RPC URL' },
{ field: 'NEXT_PUBLIC_AEGIS_CONTRACT_ID', level: 'warning', message: 'Contract ID not set' },
],
};

describe('buildDiagnosticsReport', () => {
it('produces a healthy report with ok statuses', () => {
const { report, cards } = buildDiagnosticsReport(
{
walletAddress: 'GABCDEF1234567890ABCDEF',
walletNetwork: 'TESTNET',
flags: { newMintFlow: true },
mockActive: false,
rpcUrl: 'https://rpc.example.com/v1/abcdef',
contractId: 'CABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
sdkVersion: '1.2.3',
},
validConfig,
);

expect(report.provider).toBe('LiveAegisProvider');
expect(report.rpc).toContain('rpc.example.com');
expect(report.contract).toBe('CABC...3456');
expect(report.wallet).toBe('GABC...CDEF');
expect(report.network).toBe('TESTNET');
expect(report.configValidation.valid).toBe(true);
expect(report.configValidation.errorCount).toBe(0);

const rpcCard = cards.find((c) => c.title === 'RPC URL');
expect(rpcCard?.status).toBe('ok');
const walletCard = cards.find((c) => c.title === 'Wallet Address');
expect(walletCard?.status).toBe('ok');
});

it('produces a failing report with error statuses when config is broken', () => {
const { report, cards } = buildDiagnosticsReport(
{
walletAddress: null,
walletNetwork: null,
flags: {},
mockActive: false,
rpcUrl: '',
contractId: '',
sdkVersion: '1.0.0',
},
errorConfig,
);

expect(report.configValidation.valid).toBe(false);
expect(report.configValidation.errorCount).toBe(1);
expect(report.configValidation.warningCount).toBe(1);
expect(report.configValidation.fields).toContain('NEXT_PUBLIC_RPC_URL (error)');

const rpcCard = cards.find((c) => c.title === 'RPC URL');
expect(rpcCard?.status).toBe('error');
const configCard = cards.find((c) => c.title === 'Config Validation');
expect(configCard?.status).toBe('error');
expect(configCard?.value).toContain('1 error');
const walletCard = cards.find((c) => c.title === 'Wallet Address');
expect(walletCard?.status).toBe('unknown');
});

it('shows mock warnings when mockActive is true', () => {
const { report, cards } = buildDiagnosticsReport(
{
walletAddress: 'GMOCKWALLET0000000000',
walletNetwork: null,
flags: { mockMode: true },
mockActive: true,
rpcUrl: '',
contractId: '',
sdkVersion: '0.0.0',
},
validConfig,
);

expect(report.provider).toBe('MockAegisProvider');
expect(report.rpc).toContain('[MOCK]');
expect(report.network).toBe('LOCAL_MOCK');
expect(report.configValidation.valid).toBe(true);
expect(report.configValidation.errorCount).toBe(0);

const providerCard = cards.find((c) => c.title === 'Active Provider');
expect(providerCard?.status).toBe('warning');
const configCard = cards.find((c) => c.title === 'Config Validation');
expect(configCard?.status).toBe('warning');
});
});
149 changes: 149 additions & 0 deletions src/lib/diagnostics/buildReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Diagnostics report builder — pure function that assembles a redacted,
* copyable diagnostics report from runtime inputs.
*
* Extracted from DiagnosticsPanel so the report logic is unit-testable
* without rendering React components or mocking hooks. The panel calls
* this and renders the result; tests call this directly with fixtures.
*
* Sensitive values (RPC URL paths, contract IDs, wallet addresses) are
* always redacted via the shared redact helpers — the report is safe to
* paste into a support channel.
*/

import { redactUrl, redactContractId } from './redact';
import { validateDashboardConfig, type ConfigValidationResult } from '@/config/validate';

export type DiagnosticsStatus = 'ok' | 'warning' | 'error' | 'unknown';

export interface DiagnosticsInput {
/** Wallet address from Freighter, or null when not connected. */
walletAddress: string | null;
/** Wallet network passphrase/label, or null when not connected. */
walletNetwork: string | null;
/** Feature flags snapshot. */
flags: Record<string, unknown>;
/** True when the mock provider is active. */
mockActive: boolean;
/** Raw RPC URL from env (will be redacted). */
rpcUrl: string;
/** Raw contract ID from env (will be redacted). */
contractId: string;
/** SDK version string. */
sdkVersion: string;
}

export interface DiagnosticsReport {
timestamp: string;
sdkVersion: string;
rpc: string;
contract: string;
wallet: string;
network: string;
configValidation: {
valid: boolean;
errorCount: number;
warningCount: number;
fields: string[];
};
flags: Record<string, unknown>;
provider: string;
}

export interface DiagnosticsCard {
title: string;
value: string;
status: DiagnosticsStatus;
}

export interface DiagnosticsReportResult {
report: DiagnosticsReport;
cards: DiagnosticsCard[];
}

/**
* Build a redacted diagnostics report and the status cards derived from it.
*
* @param input Runtime inputs (wallet, env, flags, mock state).
* @param configResult Optional pre-computed config validation result. When
* omitted, `validateDashboardConfig()` is called. Pass a fixture in tests
* to avoid touching `process.env`.
*/
export function buildDiagnosticsReport(
input: DiagnosticsInput,
configResult?: ConfigValidationResult,
): DiagnosticsReportResult {
const { walletAddress, walletNetwork, flags, mockActive, rpcUrl, contractId, sdkVersion } = input;

const config = configResult ?? validateDashboardConfig();
const errorCount = config.issues.filter((i) => i.level === 'error').length;
const warningCount = config.issues.filter((i) => i.level === 'warning').length;

const redactedRpc = redactUrl(rpcUrl);
const redactedContract = redactContractId(contractId);
const redactedWallet = walletAddress ? redactContractId(walletAddress) : 'Not connected';

const report: DiagnosticsReport = {
timestamp: new Date().toISOString(),
sdkVersion: mockActive ? '[MOCK] v0.0.0-local' : sdkVersion,
rpc: mockActive ? '[MOCK] Not connected — mock provider active' : redactedRpc,
contract: mockActive ? '[MOCK] Not deployed — mock provider active' : redactedContract,
wallet: redactedWallet,
network: mockActive ? 'LOCAL_MOCK' : walletNetwork || 'Not connected',
configValidation: {
valid: mockActive ? true : config.valid,
errorCount: mockActive ? 0 : errorCount,
warningCount: mockActive ? 0 : warningCount,
fields: mockActive ? [] : config.issues.map((i) => `${i.field} (${i.level})`),
},
flags,
provider: mockActive ? 'MockAegisProvider' : 'LiveAegisProvider',
};

const cards: DiagnosticsCard[] = [
{
title: 'Active Provider',
value: report.provider,
status: mockActive ? 'warning' : 'ok',
},
{
title: 'Config Validation',
value:
mockActive
? '[MOCK] Skipped — mock provider active'
: errorCount > 0
? `${errorCount} error(s), ${warningCount} warning(s)`
: warningCount > 0
? `Valid — ${warningCount} warning(s)`
: 'Valid',
status: mockActive ? 'warning' : errorCount > 0 ? 'error' : warningCount > 0 ? 'warning' : 'ok',
},
{
title: 'RPC URL',
value: mockActive ? '[MOCK] Not connected' : redactedRpc,
status: mockActive ? 'warning' : rpcUrl ? 'ok' : 'error',
},
{
title: 'Contract ID',
value: mockActive ? '[MOCK] Not deployed' : redactedContract,
status: mockActive ? 'warning' : contractId ? 'ok' : 'error',
},
{
title: 'SDK Version',
value: report.sdkVersion,
status: 'warning',
},
{
title: 'Wallet Address',
value: redactedWallet,
status: walletAddress ? 'ok' : 'unknown',
},
{
title: 'Wallet Network',
value: report.network,
status: mockActive ? 'warning' : walletNetwork ? 'ok' : 'unknown',
},
];

return { report, cards };
}
Loading