diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index b340c4e2..64d40541 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -222,3 +222,51 @@ jobs: INTEGRATION_TEST_DECIMALS=${{matrix.config.decimals}} \ INTEGRATION_TEST_NITRO_CONTRACTS_BRANCH=${{matrix.config.nitro-contracts-branch}} \ pnpm test:integration + + test-integration-anvil: + name: Test (Integration Anvil) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.30.3 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install node_modules + uses: OffchainLabs/actions/node-modules/install@main + with: + install-command: pnpm install --frozen-lockfile + cache-key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Restore anvil RPC cache + id: anvil-rpc-cache-restore + uses: actions/cache/restore@v4 + with: + path: .cache/anvil-rpc-cache.json + key: ${{ runner.os }}-anvil-rpc-cache-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-anvil-rpc-cache-${{ github.ref_name }}- + ${{ runner.os }}-anvil-rpc-cache-main- + ${{ runner.os }}-anvil-rpc-cache- + + - name: Build + run: pnpm build + + - name: Test + run: pnpm test:integration:anvil + + - name: Save anvil RPC cache + if: always() + uses: actions/cache/save@v4 + with: + path: .cache/anvil-rpc-cache.json + key: ${{ runner.os }}-anvil-rpc-cache-${{ github.ref_name }}-${{ github.run_id }} diff --git a/package.json b/package.json index 10922726..73778343 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:type": "vitest --config vitest.type.config.ts", "test:unit": "vitest --config vitest.unit.config.ts", "test:integration": "vitest --config vitest.integration.config.ts", + "test:integration:anvil": "vitest --run --config vitest.integration.anvil.config.ts", "postinstall": "patch-package", "lint": "eslint . --cache", "lint:fix": "eslint . --fix --cache", diff --git a/src/createRollup.integration.test.ts b/src/createRollup.integration.test.ts index 98114faf..426bbfb4 100644 --- a/src/createRollup.integration.test.ts +++ b/src/createRollup.integration.test.ts @@ -1,24 +1,38 @@ import { describe, it, expect } from 'vitest'; -import { createPublicClient, http, parseGwei, zeroAddress } from 'viem'; +import { type Address, createPublicClient, http, parseGwei, zeroAddress } from 'viem'; import { nitroTestnodeL2 } from './chains'; import { createRollupHelper, getNitroTestnodePrivateKeyAccounts, getInformationFromTestnode, + type PrivateKeyAccountWithPrivateKey, } from './testHelpers'; import { createRollupFetchTransactionHash } from './createRollupFetchTransactionHash'; +import { getInitializedAnvilTestStackEnv } from './integrationTestHelpers/anvilHarness'; +import { isAnvilIntegrationTestMode } from './integrationTestHelpers/injectedMode'; + +const env = isAnvilIntegrationTestMode() ? getInitializedAnvilTestStackEnv() : undefined; const parentChainPublicClient = createPublicClient({ - chain: nitroTestnodeL2, + chain: env ? env.l2.chain : nitroTestnodeL2, transport: http(), }); -// test inputs -const testnodeAccounts = getNitroTestnodePrivateKeyAccounts(); -const l3TokenBridgeDeployer = testnodeAccounts.l3TokenBridgeDeployer; -const batchPosters = [testnodeAccounts.deployer.address]; -const validators = [testnodeAccounts.deployer.address]; +let l3TokenBridgeDeployer: PrivateKeyAccountWithPrivateKey; +let batchPosters: Address[]; +let validators: Address[]; + +if (env) { + l3TokenBridgeDeployer = env.l3.accounts.tokenBridgeDeployer; + batchPosters = [env.l2.accounts.deployer.address]; + validators = [env.l2.accounts.deployer.address]; +} else { + const testnodeAccounts = getNitroTestnodePrivateKeyAccounts(); + l3TokenBridgeDeployer = testnodeAccounts.l3TokenBridgeDeployer; + batchPosters = [testnodeAccounts.deployer.address]; + validators = [testnodeAccounts.deployer.address]; +} describe(`create an AnyTrust chain that uses ETH as gas token`, async () => { const { createRollupConfig, createRollupInformation } = await createRollupHelper({ @@ -27,6 +41,8 @@ describe(`create an AnyTrust chain that uses ETH as gas token`, async () => { validators, nativeToken: zeroAddress, client: parentChainPublicClient, + customParentTimingParams: env?.l2.timingParams, + maxDataSize: env ? 104_857n : undefined, }); it(`successfully deploys core contracts through rollup creator`, async () => { @@ -58,7 +74,7 @@ describe(`create an AnyTrust chain that uses ETH as gas token`, async () => { }); describe(`create an AnyTrust chain that uses a custom gas token`, async () => { - const nativeToken = getInformationFromTestnode().l3NativeToken; + const nativeToken = env ? env.l3.nativeToken : getInformationFromTestnode().l3NativeToken; const { createRollupConfig, createRollupInformation } = await createRollupHelper({ deployer: l3TokenBridgeDeployer, @@ -66,6 +82,8 @@ describe(`create an AnyTrust chain that uses a custom gas token`, async () => { validators, nativeToken, client: parentChainPublicClient, + customParentTimingParams: env?.l2.timingParams, + maxDataSize: env ? 104_857n : undefined, }); it(`successfully deploys core contracts through rollup creator`, async () => { diff --git a/src/integrationTestHelpers/anvilHarness.ts b/src/integrationTestHelpers/anvilHarness.ts index 9193600e..7140401d 100644 --- a/src/integrationTestHelpers/anvilHarness.ts +++ b/src/integrationTestHelpers/anvilHarness.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -77,6 +77,12 @@ let cleanupHookRegistered = false; let teardownStarted = false; let l1RpcCachingProxy: RpcCachingProxy | undefined; +function prepareNitroRuntimeDir(runtimeDir: string) { + chmodSync(runtimeDir, 0o777); + mkdirSync(join(runtimeDir, 'nitro-data'), { recursive: true, mode: 0o777 }); + chmodSync(join(runtimeDir, 'nitro-data'), 0o777); +} + export async function setupAnvilTestStack(): Promise { if (envPromise) { return envPromise; @@ -97,6 +103,7 @@ export async function setupAnvilTestStack(): Promise { cleanupStaleHarnessNetworks(); runtimeDir = mkdtempSync(join(tmpdir(), 'chain-sdk-int-test')); + prepareNitroRuntimeDir(runtimeDir); dockerNetworkName = `chain-sdk-int-test-net-${Date.now()}`; createSourceDockerNetwork(dockerNetworkName); @@ -150,6 +157,7 @@ export async function setupAnvilTestStack(): Promise { await waitForRpc({ rpcUrl: l1RpcUrl, timeoutMs: 60_000, + failIfContainerExited: l1ContainerName, }); console.log('L1 Anvil node is ready\n'); @@ -220,7 +228,7 @@ export async function setupAnvilTestStack(): Promise { l2NodeConfig.node!['batch-poster']!.enable = false; l2NodeConfig.node!.staker!.enable = false; const nodeConfigPath = join(runtimeDir, 'source-l2-node-config.json'); - writeFileSync(nodeConfigPath, JSON.stringify(l2NodeConfig, null, 2)); + writeFileSync(nodeConfigPath, JSON.stringify(l2NodeConfig, null, 2), { mode: 0o644 }); // Starting L2 node (Nitro) console.log('Starting L2 Nitro node...'); @@ -250,6 +258,7 @@ export async function setupAnvilTestStack(): Promise { await waitForRpc({ rpcUrl: l2RpcUrl, timeoutMs: 60_000, + failIfContainerExited: l2ContainerName, }); console.log('L2 Nitro node is ready\n'); @@ -329,15 +338,13 @@ export async function setupAnvilTestStack(): Promise { console.log('L2 rollup creator deployed\n'); - const customGasTokenMintAmount = parseEther('10'); - await ( await customGasToken.deposit({ - value: customGasTokenMintAmount, + value: parseEther('10'), ...testConstants.LOW_L2_FEE_OVERRIDES, }) ).wait(); - console.log('[chain-sdk-int-test] source L2 shared deployer funded'); + console.log('L2 funding done\n'); const l2Chain = defineChain({ id: l2ChainId, diff --git a/src/integrationTestHelpers/dockerHelpers.ts b/src/integrationTestHelpers/dockerHelpers.ts index 34ab674c..8dc79eb2 100644 --- a/src/integrationTestHelpers/dockerHelpers.ts +++ b/src/integrationTestHelpers/dockerHelpers.ts @@ -87,18 +87,63 @@ export function createSourceDockerNetwork(networkName: string) { docker(['network', 'create', networkName]); } -export async function waitForRpc(params: { rpcUrl: string; timeoutMs: number }) { - const { rpcUrl, timeoutMs } = params; +function getContainerStatus(containerName: string): string | undefined { + try { + const status = docker(['inspect', '-f', '{{.State.Status}}', containerName]); + return status || undefined; + } catch { + return undefined; + } +} + +function getContainerLogs(containerName: string, tail = 80): string { + try { + return docker(['logs', '--tail', String(tail), containerName]); + } catch { + return ''; + } +} + +export async function waitForRpc(params: { + rpcUrl: string; + timeoutMs: number; + failIfContainerExited?: string; +}) { + const { rpcUrl, timeoutMs, failIfContainerExited } = params; const publicClient = createPublicClient({ transport: http(rpcUrl) }); const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { + if (failIfContainerExited) { + const status = getContainerStatus(failIfContainerExited); + if (status && status !== 'running') { + const logs = getContainerLogs(failIfContainerExited); + throw new Error( + `Container ${failIfContainerExited} exited with status "${status}" while waiting for RPC ${rpcUrl}.${ + logs ? `\n${logs}` : '' + }`, + ); + } + } + try { await publicClient.getChainId(); return; } catch { if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for RPC ${rpcUrl}`); + const containerContext = + failIfContainerExited && getContainerStatus(failIfContainerExited) + ? ` (container ${failIfContainerExited} status: ${getContainerStatus( + failIfContainerExited, + )})` + : ''; + const logs = + failIfContainerExited && getContainerStatus(failIfContainerExited) !== 'running' + ? getContainerLogs(failIfContainerExited) + : ''; + throw new Error( + `Timed out waiting for RPC ${rpcUrl}${containerContext}.${logs ? `\n${logs}` : ''}`, + ); } } diff --git a/src/integrationTestHelpers/rpcCachingProxy.ts b/src/integrationTestHelpers/rpcCachingProxy.ts index 2cf34d37..fdd9f984 100644 --- a/src/integrationTestHelpers/rpcCachingProxy.ts +++ b/src/integrationTestHelpers/rpcCachingProxy.ts @@ -1,3 +1,4 @@ +import { once } from 'node:events'; import { createServer, IncomingHttpHeaders } from 'node:http'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; @@ -220,10 +221,17 @@ export async function startRpcCachingProxy( } }); - server.listen(8449, '0.0.0.0'); + server.listen(0, '0.0.0.0'); + await once(server, 'listening'); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('RPC caching proxy failed to resolve a listening TCP port.'); + } return { - proxyUrl: `http://host.docker.internal:8449`, + proxyUrl: `http://host.docker.internal:${address.port}`, getSummaryLines: () => [ 'RPC proxy cache summary', ` invalidated on startup: ${stats.cacheInvalidated ? 'yes' : 'no'}`, diff --git a/vitest.integration.anvil.config.ts b/vitest.integration.anvil.config.ts new file mode 100644 index 00000000..f360d02b --- /dev/null +++ b/vitest.integration.anvil.config.ts @@ -0,0 +1,19 @@ +import { configDefaults, defineConfig, mergeConfig } from 'vitest/config'; +import commonConfig from './vitest.common'; + +export default mergeConfig( + commonConfig, + defineConfig({ + test: { + provide: { + integrationTestMode: 'anvil', + }, + // The Anvil stack boots a forked L1 and dockerized Nitro L2 in-process. + testTimeout: 45 * 60 * 1000, + setupFiles: ['./src/integrationTestHelpers/globalSetup.mjs'], + exclude: [...configDefaults.exclude], + include: ['./src/createRollup.integration.test.ts'], + fileParallelism: false, + }, + }), +); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts index 9fbef818..8f29f16d 100644 --- a/vitest.integration.config.ts +++ b/vitest.integration.config.ts @@ -5,6 +5,9 @@ export default mergeConfig( commonConfig, defineConfig({ test: { + provide: { + integrationTestMode: 'testnode', + }, // allow tests to run for 7 minutes as retryables can take a while testTimeout: 7 * 60 * 1000, exclude: [...configDefaults.exclude, './src/**/*.unit.test.ts'],