feat: Add functional health checks for test prerequisites (#65)
Replaces basic "service is up" checks with functional verification that tests can actually use the services. ## Changes ### New Health Checks - **RPC Proxy**: Verifies eth_call works and deployed contracts are accessible - **GraphQL**: Confirms Ponder has indexed data with non-zero stats - **Web App**: Validates endpoint accessibility ### Improvements - Clear error messages explain what failed and how to fix it - Checks verify actual functionality, not just HTTP 200 responses - Fails fast before tests run with cryptic errors ### Files - `tests/setup/health-checks.ts` - Core health check functions - `tests/setup/stack.ts` - Integration with waitForStackReady() - `tests/HEALTH_CHECKS.md` - Documentation and troubleshooting guide ## Error Message Example Before: ``` RPC health check failed with status 404 ``` After: ``` ❌ Stack health check failed Failed services: • RPC Proxy: RPC proxy returned HTTP 404 Expected 200, got 404. Check if Anvil is running and RPC_URL is correct. • GraphQL Indexer: GraphQL has no indexed data yet Ponder is running but has not indexed contract events. Troubleshooting: 1. Check stack logs: tail tests/.stack.log 2. Verify services are running: ./scripts/dev.sh status 3. Restart stack: ./scripts/dev.sh restart --full ``` ## Benefits - ✅ Tests fail fast with clear error messages - ✅ Catches configuration issues before tests run - ✅ Verifies services are actually usable, not just running resolves #61 Co-authored-by: johba <johba@harb.eth> Reviewed-on: https://codeberg.org/johba/harb/pulls/65
This commit is contained in:
parent
1645865c5a
commit
b1f40374cd
3 changed files with 472 additions and 42 deletions
320
tests/setup/health-checks.ts
Normal file
320
tests/setup/health-checks.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const repoRoot = resolve(__dirname, '..', '..');
|
||||
|
||||
export interface HealthCheckResult {
|
||||
success: boolean;
|
||||
service: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify RPC proxy is functional by:
|
||||
* 1. Checking eth_chainId returns the expected chain
|
||||
* 2. Making an eth_call to verify contract accessibility
|
||||
* 3. Confirming deployed contracts are accessible
|
||||
*/
|
||||
export async function checkRpcFunctional(rpcUrl: string): Promise<HealthCheckResult> {
|
||||
const service = 'RPC Proxy';
|
||||
|
||||
try {
|
||||
// Step 1: Verify basic RPC connectivity
|
||||
const chainIdResponse = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'eth_chainId',
|
||||
params: []
|
||||
}),
|
||||
});
|
||||
|
||||
if (!chainIdResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: `RPC proxy returned HTTP ${chainIdResponse.status}`,
|
||||
details: `Expected 200, got ${chainIdResponse.status}. Check if Anvil is running and RPC_URL is correct.`
|
||||
};
|
||||
}
|
||||
|
||||
const chainIdPayload = await chainIdResponse.json();
|
||||
if (!chainIdPayload?.result) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'RPC returned no chain ID',
|
||||
details: 'RPC responded but returned no result. Check if Anvil is properly initialized.'
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Load deployed contracts and verify accessibility
|
||||
const deploymentsPath = resolve(repoRoot, 'onchain', 'deployments-local.json');
|
||||
let deployments: { contracts: { Kraiken: string } };
|
||||
|
||||
try {
|
||||
const deploymentsContent = await readFile(deploymentsPath, 'utf-8');
|
||||
deployments = JSON.parse(deploymentsContent);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'Failed to load contract deployments',
|
||||
details: `Could not read ${deploymentsPath}. Run stack setup first.`
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Verify we can make eth_call to deployed Kraiken contract
|
||||
const kraikenAddress = deployments.contracts.Kraiken;
|
||||
const totalSupplyCalldata = '0x18160ddd'; // totalSupply() selector
|
||||
|
||||
const callResponse = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'eth_call',
|
||||
params: [{
|
||||
to: kraikenAddress,
|
||||
data: totalSupplyCalldata
|
||||
}, 'latest']
|
||||
}),
|
||||
});
|
||||
|
||||
if (!callResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'eth_call to Kraiken contract failed',
|
||||
details: `HTTP ${callResponse.status}. Contract may not be deployed or RPC proxy is misconfigured.`
|
||||
};
|
||||
}
|
||||
|
||||
const callPayload = await callResponse.json();
|
||||
|
||||
if (callPayload.error) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'Contract call returned RPC error',
|
||||
details: `${callPayload.error.message}. Kraiken contract at ${kraikenAddress} may not be deployed.`
|
||||
};
|
||||
}
|
||||
|
||||
if (!callPayload?.result) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'Contract call returned no result',
|
||||
details: 'eth_call responded but returned no data. Contract may not be deployed.'
|
||||
};
|
||||
}
|
||||
|
||||
// Verify we got a valid response (should be a hex-encoded uint256)
|
||||
const result = callPayload.result as string;
|
||||
if (!result.startsWith('0x')) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'Contract call returned invalid data',
|
||||
details: `Expected hex-encoded value, got: ${result}`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
service,
|
||||
message: 'RPC proxy is functional and contracts are accessible'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'RPC health check failed with exception',
|
||||
details: `${errorMessage}. Check if RPC endpoint ${rpcUrl} is accessible.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify GraphQL has indexed contract data by:
|
||||
* 1. Checking GraphQL endpoint responds
|
||||
* 2. Querying for stats to verify Ponder has indexed events
|
||||
* 3. Confirming indexed data is non-zero (indicates successful indexing)
|
||||
*/
|
||||
export async function checkGraphqlIndexed(graphqlUrl: string): Promise<HealthCheckResult> {
|
||||
const service = 'GraphQL Indexer';
|
||||
|
||||
try {
|
||||
// Query for stats which should be populated after indexing
|
||||
const response = await fetch(graphqlUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query {
|
||||
stats(id: "0x01") {
|
||||
kraikenTotalSupply
|
||||
stakeTotalSupply
|
||||
}
|
||||
}
|
||||
`
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: `GraphQL endpoint returned HTTP ${response.status}`,
|
||||
details: `Expected 200, got ${response.status}. Check if Ponder is running.`
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (payload.errors) {
|
||||
const errorMsg = payload.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'GraphQL query returned errors',
|
||||
details: `${errorMsg}. Check Ponder logs for indexing issues.`
|
||||
};
|
||||
}
|
||||
|
||||
// Verify stats data exists
|
||||
const stats = payload?.data?.stats;
|
||||
if (!stats) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'GraphQL has no indexed data yet',
|
||||
details: 'Ponder is running but has not indexed contract events. Wait longer or check Ponder logs.'
|
||||
};
|
||||
}
|
||||
|
||||
// Verify we have actual indexed data (non-zero supply indicates indexing happened)
|
||||
const kraikenSupply = BigInt(stats.kraikenTotalSupply || '0');
|
||||
if (kraikenSupply === 0n) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'GraphQL indexed data is empty',
|
||||
details: 'Stats exist but Kraiken supply is 0. Indexing may not have completed or contracts not initialized.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
service,
|
||||
message: 'GraphQL has successfully indexed contract data'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'GraphQL health check failed with exception',
|
||||
details: `${errorMessage}. Check if GraphQL endpoint ${graphqlUrl} is accessible.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webapp is accessible
|
||||
*/
|
||||
export async function checkWebAppAccessible(webAppUrl: string): Promise<HealthCheckResult> {
|
||||
const service = 'Web App';
|
||||
|
||||
try {
|
||||
const url = webAppUrl.endsWith('/') ? `${webAppUrl}app/` : `${webAppUrl}/app/`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 308) {
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: `Web app returned HTTP ${response.status}`,
|
||||
details: `Expected 200 or 308, got ${response.status}. Check if webapp is running.`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
service,
|
||||
message: 'Web app is accessible'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
service,
|
||||
message: 'Web app health check failed',
|
||||
details: `${errorMessage}. Check if webapp endpoint ${webAppUrl} is accessible.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all functional health checks and return detailed results
|
||||
*/
|
||||
export async function runAllHealthChecks(options: {
|
||||
rpcUrl: string;
|
||||
webAppUrl: string;
|
||||
graphqlUrl: string;
|
||||
}): Promise<HealthCheckResult[]> {
|
||||
const results = await Promise.all([
|
||||
checkRpcFunctional(options.rpcUrl),
|
||||
checkWebAppAccessible(options.webAppUrl),
|
||||
checkGraphqlIndexed(options.graphqlUrl),
|
||||
]);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format health check results into a readable error message
|
||||
*/
|
||||
export function formatHealthCheckError(results: HealthCheckResult[]): string {
|
||||
const failures = results.filter(r => !r.success);
|
||||
|
||||
if (failures.length === 0) {
|
||||
return 'All health checks passed';
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'❌ Stack health check failed',
|
||||
'',
|
||||
'Failed services:',
|
||||
];
|
||||
|
||||
for (const failure of failures) {
|
||||
lines.push(` • ${failure.service}: ${failure.message}`);
|
||||
if (failure.details) {
|
||||
lines.push(` ${failure.details}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Troubleshooting:');
|
||||
lines.push(' 1. Check stack logs: tail tests/.stack.log');
|
||||
lines.push(' 2. Verify services are running: ./scripts/dev.sh status');
|
||||
lines.push(' 3. Restart stack: ./scripts/dev.sh restart --full');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue