diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3b47ba97..117e9165 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,43 +22,85 @@ jobs: - name: Install dependencies run: npm ci - # TODO: Change to algorandfoundation/algokit-polytest once https://github.com/algorandfoundation/algokit-polytest/pull/18 is merged - name: Setup Polytest - uses: aorumbayev/algokit-polytest/.github/actions/setup-polytest@main + uses: algorandfoundation/algokit-polytest/.github/actions/setup-polytest@main - name: Validate polytest algod_client tests run: npm run polytest:validate-algod - pull_request: + build-and-test: needs: setup-polytest - uses: makerxstudio/shared-config/.github/workflows/node-ci.yml@main - with: - node-version: 20.x - working-directory: ./ - run-commit-lint: true - run-build: true - pre-test-script: | - pipx install algokit - algokit localnet start - npx --yes wait-on tcp:4001 -t 30000 - - # Ensure Transaction class has all keys from TransactionParams - npm run check-types - audit-script: | - npm run audit - check_docs: runs-on: ubuntu-latest steps: - - name: Clone repository - uses: actions/checkout@v3 + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Commit lint + run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose + + - name: Lint + run: npm run lint --if-present + + - name: Check types + run: npm run check-types --if-present + + # Start localnet for integration tests + - name: Start LocalNet + run: | + pipx install algokit + algokit localnet start + npx --yes wait-on tcp:4001 -t 30000 + + # Start mock servers using composite action + - name: Start algod mock server + uses: algorandfoundation/algokit-polytest/.github/actions/run-mock-server@main + with: + client: algod + + - name: Start indexer mock server + uses: algorandfoundation/algokit-polytest/.github/actions/run-mock-server@main + with: + client: indexer + + - name: Start kmd mock server + uses: algorandfoundation/algokit-polytest/.github/actions/run-mock-server@main + with: + client: kmd + + - name: Run tests + run: npm run test --if-present -- --silent + + - name: Audit + run: npm run audit + + - name: Build + run: npm run build + + check-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 with: fetch-depth: 1 - - name: Use Node.js 20.x - uses: actions/setup-node@v3 + + - name: Setup Node.js + uses: actions/setup-node@v4 with: node-version: 20.x + - name: Check docs are up to date - shell: bash run: | npm ci --ignore-scripts npm run generate:code-docs diff --git a/README.md b/README.md index b9aeff34..2d53cddd 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,37 @@ npm run test ### Mock Server for Client Tests -The `algod_client`, `indexer_client`, and `kmd_client` packages use a Docker-based mock server for deterministic API testing. By default, running tests will automatically start the mock server container. +The `algod_client`, `indexer_client`, and `kmd_client` packages use a mock server for deterministic API testing against pre-recorded HAR files. The mock server is managed externally (not by the test framework). -To use an external mock server (e.g., for local development): +**In CI:** Mock servers are automatically started via the [algokit-polytest](https://github.com/algorandfoundation/algokit-polytest) setup. + +**Local development:** + +1. Clone algokit-polytest and start the mock servers: + +```bash +npm run polytest:start-mock-servers +``` + +This starts algod (port 8000), indexer (port 8002), and kmd (port 8001) in the background. + +2. Set environment variables and run tests. + +| Environment Variable | Description | Default Port | +| -------------------- | ----------------------- | ------------ | +| `MOCK_ALGOD_URL` | Algod mock server URL | 8000 | +| `MOCK_INDEXER_URL` | Indexer mock server URL | 8002 | +| `MOCK_KMD_URL` | KMD mock server URL | 8001 | + +Environment variables can also be set via `.env` file in project root (copy from `.env.template`). + +```bash +# after setting env vars via export or .env file +npm run test +``` + +3. Stop servers when done: ```bash -# Set one or more of these environment variables -export MOCK_ALGOD_URL=http://localhost:18000 -export MOCK_INDEXER_URL=http://localhost:18002 -export MOCK_KMD_URL=http://localhost:18001 +npm run polytest:stop-mock-servers ``` diff --git a/package-lock.json b/package-lock.json index 04cb3b3f..d71cf298 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1472,6 +1472,7 @@ "version": "6.1.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.0.0", @@ -2588,6 +2589,7 @@ "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2611,6 +2613,7 @@ "version": "8.16.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.16.0", @@ -2643,6 +2646,7 @@ "version": "8.16.0", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -2960,6 +2964,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3682,6 +3687,7 @@ "version": "9.0.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4228,6 +4234,7 @@ "version": "9.31.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5707,6 +5714,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -8544,6 +8552,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9693,6 +9702,7 @@ "integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.95.0", "@rolldown/pluginutils": "1.0.0-beta.45" @@ -9840,6 +9850,7 @@ "integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -10561,6 +10572,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10749,6 +10761,7 @@ "version": "5.4.5", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10908,6 +10921,7 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -11001,6 +11015,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11014,6 +11029,7 @@ "integrity": "sha512-4H+J28MI5oeYgGg3h5BFSkQ1g/2GKK1IR8oorH3a6EQQbb7CwjbnyBjH4PGxw9/6vpwAPNzaeUMp4Js4WJmdXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.5", "@vitest/mocker": "4.0.5", diff --git a/package.json b/package.json index c9b39e3f..843f8c51 100644 --- a/package.json +++ b/package.json @@ -89,9 +89,10 @@ "generate:client-algod": "cd oas-generator && uv run oas-generator https://raw.githubusercontent.com/algorandfoundation/algokit-oas-generator/main/specs/algod.oas3.json --output ../packages/algod_client/ --package-name algod_client --description \"TypeScript client for algod interaction.\" --verbose && cd ../packages/algod_client/ && npm run lint:fix && npm run format && cd ..", "generate:client-indexer": "cd oas-generator && uv run oas-generator https://raw.githubusercontent.com/algorandfoundation/algokit-oas-generator/main/specs/indexer.oas3.json --output ../packages/indexer_client/ --package-name indexer_client --description \"TypeScript client for indexer interaction.\" --verbose && cd ../packages/indexer_client/ && npm run lint:fix && npm run format && cd ..", "generate:client-kmd": "cd oas-generator && uv run oas-generator https://raw.githubusercontent.com/algorandfoundation/algokit-oas-generator/main/specs/kmd.oas3.json --output ../packages/kmd_client/ --package-name kmd_client --description \"TypeScript client for kmd interaction.\" --verbose && cd ../packages/kmd_client/ && npm run lint:fix && npm run format && cd ..", - "mock-server:start-algod": "./.polytest_algokit-polytest/resources/mock-server/scripts/start_server.sh algod", "polytest:validate-algod": "polytest --config test_configs/algod_client.jsonc --git 'https://github.com/algorandfoundation/algokit-polytest#main' validate -t vitest", - "polytest:generate-algod": "polytest --config test_configs/algod_client.jsonc --git 'https://github.com/algorandfoundation/algokit-polytest#main' generate -t vitest" + "polytest:generate-algod": "polytest --config test_configs/algod_client.jsonc --git 'https://github.com/algorandfoundation/algokit-polytest#main' generate -t vitest", + "polytest:start-mock-servers": ".polytest_algokit-polytest/resources/mock-server/scripts/start_all_servers.sh", + "polytest:stop-mock-servers": ".polytest_algokit-polytest/resources/mock-server/scripts/stop_all_servers.sh" }, "overrides": { "esbuild": "0.25.0" diff --git a/packages/testing/src/globalSetup.ts b/packages/testing/src/globalSetup.ts index 6aed3328..efc5f09f 100644 --- a/packages/testing/src/globalSetup.ts +++ b/packages/testing/src/globalSetup.ts @@ -6,7 +6,7 @@ import { config } from 'dotenv' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { startMockServer, stopAllMockServers, type ClientType, type MockServer, MOCK_PORTS } from './mockServer' +import { getMockServer, type ClientType, type MockServer } from './mockServer' const currentDir = resolve(fileURLToPath(import.meta.url), '..') const projectRoot = resolve(currentDir, '..', '..', '..') @@ -19,23 +19,18 @@ export function createGlobalSetup(clientType: ClientType) { let mockServer: MockServer | null = null return async function setup(): Promise<() => Promise> { - log(`[MockServer] Starting ${clientType} mock server...`) + log(`[MockServer] Connecting to ${clientType} mock server...`) try { - mockServer = await startMockServer(clientType) + mockServer = await getMockServer(clientType) log(`[MockServer] ${clientType} server ready at ${mockServer.baseUrl}`) - process.env[`MOCK_${clientType.toUpperCase()}_SERVER`] = mockServer.baseUrl - process.env[`MOCK_${clientType.toUpperCase()}_PORT`] = String(MOCK_PORTS[clientType].host) - return async () => { - log(`[MockServer] Stopping ${clientType} mock server...`) - if (mockServer) await mockServer.stop() - await stopAllMockServers() - log(`[MockServer] ${clientType} server stopped`) + log(`[MockServer] Disconnecting from ${clientType} mock server...`) + log(`[MockServer] ${clientType} server disconnected`) } } catch (error) { - console.error(`[MockServer] Failed to start ${clientType} server:`, error) + console.error(`[MockServer] Failed to connect to ${clientType} server:`, error) throw error } } diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index 9218e08c..3e5801a6 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -1,13 +1,11 @@ export { type ClientType, type MockServer, - MOCK_PORTS, EXTERNAL_URL_ENV_VARS, DEFAULT_TOKEN, - CONTAINER_PREFIX, - startMockServer, - stopAllMockServers, - getStartedContainers, + MOCK_PORTS, + getMockServer, + checkServerHealth, TEST_ADDRESS, TEST_APP_ID, TEST_APP_ID_WITH_BOXES, diff --git a/packages/testing/src/mockServer.ts b/packages/testing/src/mockServer.ts index dbfa4c81..8e519228 100644 --- a/packages/testing/src/mockServer.ts +++ b/packages/testing/src/mockServer.ts @@ -1,244 +1,117 @@ /** * Mock server infrastructure for algod/indexer/kmd client testing. * - * Uses Docker-based mock servers that replay pre-recorded HAR files. - * Set MOCK_ALGOD_URL / MOCK_INDEXER_URL / MOCK_KMD_URL to use an external server. + * Connects to externally managed mock servers that replay pre-recorded HAR files. + * The mock server must be started separately (via GitHub Action in CI, or manually via bun for local dev). + * + * Set MOCK_ALGOD_URL / MOCK_INDEXER_URL / MOCK_KMD_URL environment variables to specify server URLs. */ -import { execSync } from 'node:child_process' -import { createConnection } from 'node:net' - +/** Supported client types for mock servers */ export type ClientType = 'algod' | 'indexer' | 'kmd' -export const MOCK_PORTS: Record = { - algod: { host: 18000, container: 8000 }, - indexer: { host: 18002, container: 8002 }, - kmd: { host: 18001, container: 8001 }, -} - +/** Environment variable names for external mock server URLs */ export const EXTERNAL_URL_ENV_VARS: Record = { algod: 'MOCK_ALGOD_URL', indexer: 'MOCK_INDEXER_URL', kmd: 'MOCK_KMD_URL', } +/** Default token used for mock server authentication */ export const DEFAULT_TOKEN = 'a'.repeat(64) -export const CONTAINER_PREFIX = 'algokit_utils_ts_mock' -export const MOCK_SERVER_IMAGE = 'ghcr.io/aorumbayev/polytest-mock-server:latest' -const startedContainers = new Set() +/** Default ports for mock servers when running locally (matches algokit-polytest defaults) */ +export const MOCK_PORTS = { + algod: { host: 8000 }, + indexer: { host: 8002 }, + kmd: { host: 8001 }, +} as const +/** Mock server instance representing a connection to an external server */ export interface MockServer { - containerId: string - name: string - clientType: ClientType - port: number - isOwner: boolean + /** Base URL of the mock server */ baseUrl: string - stop: () => Promise -} - -async function waitForPort(port: number, timeout = 30000): Promise { - const start = Date.now() - while (Date.now() - start < timeout) { - try { - await new Promise((resolve, reject) => { - const socket = createConnection({ host: '127.0.0.1', port }, () => { - socket.destroy() - resolve() - }) - socket.on('error', reject) - socket.setTimeout(1000, () => { - socket.destroy() - reject(new Error('timeout')) - }) - }) - return true - } catch { - await new Promise((resolve) => setTimeout(resolve, 100)) - } - } - return false + /** Type of client this server mocks */ + clientType: ClientType } -async function waitForHealth(port: number, timeout = 30000): Promise { +/** + * Check if a server is reachable by performing a health check. + * + * @param url - The base URL of the server to check + * @param timeout - Maximum time to wait for the server to respond (default: 5000ms) + * @returns Promise resolving to true if server is healthy, false otherwise + */ +export async function checkServerHealth(url: string, timeout = 5000): Promise { + const healthUrl = `${url.replace(/\/$/, '')}/health` const start = Date.now() - const url = `http://127.0.0.1:${port}/health` while (Date.now() - start < timeout) { try { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 2000) try { - await fetch(url, { method: 'GET', signal: controller.signal }) + await fetch(healthUrl, { signal: controller.signal }) clearTimeout(timeoutId) + // Any HTTP response (including 500) indicates the server is reachable + // The mock server returns 500 for unrecorded endpoints like /health return true } catch (error) { clearTimeout(timeoutId) - throw error - } - } catch { - await new Promise((resolve) => setTimeout(resolve, 200)) - } - } - return false -} - -function getContainerId(name: string): string | null { - try { - const result = execSync(`docker ps -q -f "name=^${name}$"`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }) - return result.trim() || null - } catch { - return null - } -} - -function getExternalServerUrl(clientType: ClientType): string | undefined { - return process.env[EXTERNAL_URL_ENV_VARS[clientType]] -} - -async function checkExternalServer(url: string, timeout = 5000): Promise<{ isHealthy: boolean; port: number }> { - const parsedUrl = new URL(url) - const port = parsedUrl.port ? parseInt(parsedUrl.port) : 80 - const healthUrl = `${url.replace(/\/$/, '')}/health` - - const start = Date.now() - while (Date.now() - start < timeout) { - try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 2000) - try { - await fetch(healthUrl, { method: 'GET', signal: controller.signal }) - clearTimeout(timeoutId) - return { isHealthy: true, port } - } catch (error) { - clearTimeout(timeoutId) - if (error instanceof TypeError && error.message.includes('fetch')) { + // If it's a network error, retry + if (error instanceof Error && error.name === 'AbortError') { await new Promise((resolve) => setTimeout(resolve, 200)) continue } - return { isHealthy: true, port } + throw error } } catch { await new Promise((resolve) => setTimeout(resolve, 200)) } } - return { isHealthy: false, port } -} - -function createMockServer(containerId: string, name: string, clientType: ClientType, port: number, isOwner: boolean): MockServer { - return { - containerId, - name, - clientType, - port, - isOwner, - baseUrl: `http://127.0.0.1:${port}`, - stop: async () => { - if (!isOwner) return - if (startedContainers.has(name)) { - try { - execSync(`docker rm -f ${containerId}`, { stdio: 'pipe' }) - } catch { - // Ignore cleanup errors - } - startedContainers.delete(name) - } - }, - } + return false } /** - * Start or connect to a mock server. - * Priority: external URL > existing container > new container + * Get a mock server instance for the specified client type. + * + * Reads the appropriate environment variable (MOCK_ALGOD_URL, MOCK_INDEXER_URL, or MOCK_KMD_URL) + * and validates that the server is reachable before returning a MockServer instance. + * + * @param clientType - The type of client to get a mock server for ('algod', 'indexer', or 'kmd') + * @returns Promise resolving to a MockServer instance + * @throws Error if the environment variable is not set or the server is not reachable + * + * @example + * ```typescript + * const server = await getMockServer('algod') + * const client = new AlgodClient(DEFAULT_TOKEN, server.baseUrl) + * // ... run tests ... + * ``` */ -export async function startMockServer(clientType: ClientType): Promise { - const externalUrl = getExternalServerUrl(clientType) - if (externalUrl) { - const { isHealthy, port } = await checkExternalServer(externalUrl) - if (isHealthy) { - return createMockServer('external', 'external', clientType, port, false) - } - throw new Error(`${EXTERNAL_URL_ENV_VARS[clientType]}=${externalUrl} set but server not responding`) - } - - const { host: hostPort, container: containerPort } = MOCK_PORTS[clientType] - const containerName = `${CONTAINER_PREFIX}_${clientType}` - - const existingId = getContainerId(containerName) - if (existingId) { - const isReady = await waitForPort(hostPort) - if (!isReady) { - throw new Error(`Existing ${clientType} server not responding on port ${hostPort}`) - } - return createMockServer(existingId, containerName, clientType, hostPort, false) +export async function getMockServer(clientType: ClientType): Promise { + const envVar = EXTERNAL_URL_ENV_VARS[clientType] + const externalUrl = process.env[envVar] + + if (!externalUrl) { + throw new Error( + `Environment variable ${envVar} is not set. ` + + `Please start the mock server externally and set the URL. ` + + `See the "Mock Server for Client Tests" section in README.md for local development setup.`, + ) } - try { - execSync(`docker rm -f ${containerName}`, { stdio: 'pipe' }) - } catch { - // Container doesn't exist + const isHealthy = await checkServerHealth(externalUrl) + if (!isHealthy) { + throw new Error( + `Mock ${clientType} server at ${externalUrl} is not reachable. ` + `Please ensure the server is running and accessible.`, + ) } - const cmd = [ - 'docker run -d', - `--name ${containerName}`, - `-p ${hostPort}:${containerPort}`, - `-e ${clientType.toUpperCase()}_PORT=${containerPort}`, - MOCK_SERVER_IMAGE, + return { + baseUrl: externalUrl.replace(/\/$/, ''), clientType, - ].join(' ') - - let containerId: string - try { - containerId = execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() - } catch (error) { - throw new Error(`Failed to start ${clientType} mock server: ${error instanceof Error ? error.message : error}`) } - - startedContainers.add(containerName) - - const portReady = await waitForPort(hostPort) - if (!portReady) { - try { - execSync(`docker rm -f ${containerId}`, { stdio: 'pipe' }) - } catch { - // Ignore - } - startedContainers.delete(containerName) - throw new Error(`${clientType} mock server failed to start`) - } - - const healthReady = await waitForHealth(hostPort, 30000) - if (!healthReady) { - try { - execSync(`docker rm -f ${containerId}`, { stdio: 'pipe' }) - } catch { - // Ignore - } - startedContainers.delete(containerName) - throw new Error(`${clientType} mock server health check failed`) - } - - return createMockServer(containerId, containerName, clientType, hostPort, true) -} - -export async function stopAllMockServers(): Promise { - for (const name of startedContainers) { - try { - execSync(`docker rm -f ${name}`, { stdio: 'pipe' }) - } catch { - // Ignore - } - } - startedContainers.clear() -} - -export function getStartedContainers(): string[] { - return Array.from(startedContainers) } // Test data constants matching mock server recordings